Skip to content

feat: 0.5 ytm_dedupe — auto-clean ytmusic duplicate likes - #9

Merged
zeikar merged 1 commit into
mainfrom
feat/0.5-ytm-dedupe
May 11, 2026
Merged

feat: 0.5 ytm_dedupe — auto-clean ytmusic duplicate likes#9
zeikar merged 1 commit into
mainfrom
feat/0.5-ytm-dedupe

Conversation

@zeikar

@zeikar zeikar commented May 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds ytm_dedupe ActionKind that maps each ytmusic-source duplicate_in_source finding (count=2 only) to one rate_song(video_id, "INDIFFERENT") call.
  • Routes via _parse_duplicate_in_source_reason() — regex-fullmatch + len(positions) == count validation. Any deviation (malformed reason, multi-source, non-ytmusic, count mismatch, N>=3, missing video_id) produces a SkipRecord. Never defaults to a destructive write.
  • Terminal-on-attempt: execute() flips status='applied' for ytm_dedupe on success OR failure. INDIFFERENT is non-idempotent and a client error can't distinguish "server processed and client errored" from "server didn't process" — auto-retry within the same Diagnosis would risk over-removal. Real failures self-correct via the next compare-likes.

Action mapping (new row)

issue_type Action YouTube call YT Music call Auto-apply
duplicate_in_source YT Music unlike (-1 entry) rate_song("INDIFFERENT") ytmusic source, N=2 only

Behind the safety guards

Live probes against a real account (see .hyperclaude/plans/ v5 pre-flight):

  • rate_song(LIKE) is not idempotent — every call appends a new LM entry. This is how 0.4 drift sync produced the dups in the first place.
  • rate_song(INDIFFERENT) removes one entry per call. Propagation is minute-scale.
  • ytmusic backend appears to cap LIKE-appending at N=2 (probe showed count=2 + LIKE -> count=2 after 30s). Wild N>=3 dups are extremely rare.
  • N=2 is live-probed. N>=3 is unprobed → conservatively skipped with a clear SkipRecord reason. File an issue if you ever see one in the wild.

Test plan

  • uv run pytest tests/ — 244 passed
  • uv run ruff format --check . — clean
  • uv run ruff check — clean
  • Unit tests for _parse_duplicate_in_source_reason cover all parse-fail paths (count mismatch, multi-source contamination, malformed, roundtrip with builder)
  • Planner branch tests cover ytmusic-N=2 happy path + youtube-skip + N>=3 skip + count-mismatch skip + missing-video-id skip + applied-item-not-re-picked guard
  • Dispatcher tests cover success, failure-still-marks-applied (non-idempotent semantics), skip
  • CLI integration tests cover ytm-only success (no YouTube method calls), ytm-only failure (exits non-zero but item marked applied), --limit 1 with mixed dedupe + unlike

Docs

  • README action-mapping row replaced; existing applied invariant bullet revised in-place to carve out ytm_dedupe; new safety bullet on pre-dedupe re-scan.
  • CLAUDE.md state-model bullet revised in-place + new ytm_dedupe bullet.
  • models.py docstrings for SyncAttempt.kind and DiagnosisItem.duplicate_in_source updated.

Post-merge

Per CLAUDE.md release procedure: version bump (0.4.1 → 0.5.0), uv sync, README MVP status + roadmap, ARCHITECTURE.md roadmap, then tag v0.5.0 + gh release create. Handled as a separate chore commit on main after merge.

Maps ytmusic-source `duplicate_in_source` findings with count=2 to a
single `rate_song(video_id, "INDIFFERENT")` call. Routing goes through
`_parse_duplicate_in_source_reason()` — regex-fullmatch with
`len(positions) == count` validation. Malformed, multi-source,
non-ytmusic, count-mismatched, or N>=3 reasons all produce a SkipRecord;
we never default to a destructive write.

Terminal-on-attempt: `execute()` flips `status='applied'` for ytm_dedupe
regardless of success or failure. INDIFFERENT is non-idempotent, and a
client error can't distinguish "server processed, client errored" from
"server didn't process" — auto-retry within the same Diagnosis would
risk over-removal. Real failures self-correct via the next compare-likes
(lingering dup -> new finding -> new attempt).

Live-probed for N=2; N>=3 is conservatively skipped (and ytmusic backend
appears to cap implicit LIKE-appending at N=2 anyway, so wild N>=3 is
extremely rare).
Copilot AI review requested due to automatic review settings May 11, 2026 22:04
@zeikar
zeikar merged commit a66f195 into main May 11, 2026
3 checks passed
@zeikar
zeikar deleted the feat/0.5-ytm-dedupe branch May 11, 2026 22:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an auto-remediation path for YT Music “duplicate likes in source” findings by introducing a new ytm_dedupe action that issues a single rate_song(video_id, "INDIFFERENT") per eligible finding (ytmusic source, N=2 only), with conservative parsing/validation and “terminal-on-attempt” semantics to avoid accidental over-removal from non-idempotent writes.

Changes:

  • Add YTMusicClient.unlike_song() and wire a new ytm_dedupe action through plan()execute()/_dispatch(), including _parse_duplicate_in_source_reason() validation and skip-on-any-deviation behavior.
  • Implement terminal-on-attempt semantics for ytm_dedupe (mark DiagnosisItem.status='applied' even on failure; still record a failed SyncAttempt).
  • Expand unit/CLI coverage and update docs to describe the new action and its safety constraints.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/test_ytmusic_client.py Adds tests for unlike_song() behavior and error-wrapping semantics.
tests/test_sync.py Adds planner/dispatcher tests for ytm_dedupe, parsing, skip shapes, and terminal-on-attempt behavior.
tests/test_cli.py Adds CLI integration tests for ytm-only dedupe runs, failure semantics, and --limit behavior.
src/likesurgeon/ytmusic_client.py Introduces unlike_song() implemented via rate_song(..., "INDIFFERENT") with error wrapping.
src/likesurgeon/sync.py Adds ytm_dedupe action kind, reason parsing, planner mapping, dispatcher support, quota/summarize updates, and terminal-on-attempt handling.
src/likesurgeon/models.py Updates docstrings for duplicate_in_source and SyncAttempt.kind to include ytm_dedupe.
src/likesurgeon/cli.py Adjusts sync client construction and keeps YouTube scope/write gating based on planned actions.
README.md Updates action mapping and state model documentation for ytm_dedupe and safety guidance.
CLAUDE.md Updates sync state model guidance to document ytm_dedupe constraints and operational cautions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread README.md
- `DiagnosisItem.status` only flips to `'applied'` when every API call for the action succeeded. For drift that means BOTH halves. Anything else (failure, low-confidence skip) leaves it at `'open'` so the next `sync` re-evaluates it.
- `DiagnosisItem.status` only flips to `'applied'` when every API call for the action succeeded — *except* for `ytm_dedupe`, which is non-idempotent and flips to `'applied'` after any attempt (success OR failure) to prevent auto-retry over-removal. For drift that means BOTH halves. For other failures and low-confidence skips, the status stays at `'open'` so the next `sync` re-evaluates.
- **Re-scan ytmusic + compare-likes immediately before a dedupe sync.** A stale `duplicate_in_source` finding can remove the only remaining LM entry if the dup was already fixed manually or by propagation between diagnose and sync. Eventual consistency also runs the other way: avoid re-scanning for a few minutes *after* a dedupe sync, since a stale snapshot can recreate the same finding and the next sync over-removes.
- Re-running `sync` is idempotent: applied items are skipped; failures and previously-skipped findings are re-tried (so lowering `--drift-min-confidence` will pick up borderline drifts on the next run).
Comment thread src/likesurgeon/models.py
Comment on lines 197 to 200
``kind`` is one of ``yt_unlike``, ``ytm_like``, ``yt_relike_like``,
``yt_relike_unlike`` — the two halves of a drift fix get separate rows
``yt_relike_unlike``, ``ytm_dedupe`` — the two halves of a drift fix get separate rows
so the audit trail stays atomic per HTTP call. ``status`` is one of
``applied``, ``failed``, ``skipped``. ``reason`` carries sync-side
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants