feat: 0.5 ytm_dedupe — auto-clean ytmusic duplicate likes - #9
Merged
Conversation
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).
There was a problem hiding this comment.
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 newytm_dedupeaction throughplan()→execute()/_dispatch(), including_parse_duplicate_in_source_reason()validation and skip-on-any-deviation behavior. - Implement terminal-on-attempt semantics for
ytm_dedupe(markDiagnosisItem.status='applied'even on failure; still record a failedSyncAttempt). - 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.
| - `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 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ytm_dedupeActionKind that maps each ytmusic-sourceduplicate_in_sourcefinding (count=2 only) to onerate_song(video_id, "INDIFFERENT")call._parse_duplicate_in_source_reason()— regex-fullmatch +len(positions) == countvalidation. Any deviation (malformed reason, multi-source, non-ytmusic, count mismatch, N>=3, missing video_id) produces aSkipRecord. Never defaults to a destructive write.execute()flipsstatus='applied'forytm_dedupeon success OR failure.INDIFFERENTis 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 nextcompare-likes.Action mapping (new row)
issue_typeduplicate_in_sourcerate_song("INDIFFERENT")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.SkipRecordreason. File an issue if you ever see one in the wild.Test plan
uv run pytest tests/— 244 passeduv run ruff format --check .— cleanuv run ruff check— clean_parse_duplicate_in_source_reasoncover all parse-fail paths (count mismatch, multi-source contamination, malformed, roundtrip with builder)--limit 1with mixed dedupe + unlikeDocs
appliedinvariant bullet revised in-place to carve outytm_dedupe; new safety bullet on pre-dedupe re-scan.models.pydocstrings forSyncAttempt.kindandDiagnosisItem.duplicate_in_sourceupdated.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 tagv0.5.0+gh release create. Handled as a separate chore commit onmainafter merge.