Persist unmatched Letterboxd rows and retry them as the library grows (#25) - #26
Open
Tailes14 wants to merge 6 commits into
Open
Persist unmatched Letterboxd rows and retry them as the library grows (#25)#26Tailes14 wants to merge 6 commits into
Tailes14 wants to merge 6 commits into
Conversation
A Letterboxd import can only rate films the server already has. Every other row is counted as "unmatched" and dropped, so a member importing a 2000-film history into a 400-film library silently loses ~1600 ratings with no way back except re-uploading the whole export later. This adds the storage those rows need to survive: a per-user queue at data/InternalRating/letterboxd-pending.json, following the same shape as LetterboxdPushLedger (SemaphoreSlim + JSON + atomic tmp/move). It is the inverse of that ledger — the ledger records work already done so it can be skipped, this records work NOT done so it can be repeated — and unlike the ledger it shrinks, because every row that finally matches is removed. The dedupe key is (kind, normalized title, year), plus the watched date for diary rows only. Rewatches are genuinely separate entries and collapsing them on title+year would keep just one; ratings deliberately exclude the date so a re-export with a shifted timestamp updates in place instead of queueing a second copy. Capped at 10k rows per user. Over the cap new rows are dropped rather than evicting existing ones, which have already been retried and cost nothing to keep. Gated behind RetainUnmatchedLetterboxdRows, default off: this writes a file sized by the part of a member's history the server does not have, which an admin should opt into rather than inherit on upgrade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two halves of the feature, in the one service that owns both. CAPTURE. Every site that previously dropped a row now parks it instead: the four CSV importers (ratings, watchlist, likes, diary) and the RSS sync. RSS matters most — a CSV row can always be recovered by re-uploading the export, but an RSS entry is seen once, lastSyncedGuid moves past it, and it scrolls off the feed. Rating a film on Letterboxd the week before it lands in Jellyfin used to lose that rating permanently. Rows are collected per CSV and written in a single merge rather than one file write per row; an import against a small library can leave thousands unmatched. Queue failures are logged and swallowed — a partial import is strictly better than failing one that otherwise worked. Two parsers moved above their match check so an unmatched row carries the data it needs: the rating date (imported ratings must keep their original chronology, or they all cluster at UtcNow and Newest-rated sorts to junk) and the diary watched date (part of that row's identity in the queue). REPLAY. RetryPendingAsync re-runs the rows against the current library, with the outcomes the issue asks for: matched and written, or already satisfied, removes the row; no match keeps it and stamps LastTriedAt; a failed write keeps it so a transient error retries. An existing rating counts as satisfied rather than something to overwrite — rating it in StarTrack directly is a newer, deliberate act than a row from an old export. Deliberately not gated on the config flag: turning the setting off should stop new rows being queued, not strand a backlog already on disk. GetLibraryFingerprint is the gate that makes replaying affordable on a 10-minute task. It returns movie count plus newest DateCreated from two indexed queries — the local counterpart of the ETag the RSS poll already sends, used the same way. Here it is provably lossless rather than a heuristic: the only thing that can resolve a pending row is an item appearing, so an unmoved library means a retry cannot succeed. The cost it avoids is BuildMovieLookup — a full GetItemList plus a File.Exists per item. Known gap, documented at the method: an in-place metadata edit that fixes a title moves neither part of the fingerprint. ItemSortBy has no DateLastSaved in 10.11, so catching it would mean materialising the library — the exact cost being avoided. Manual "Sync now" always forces a pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Registers the store and hooks the replay into the paths that already exist, rather than adding an API route for it. The scheduled task is the only place that can drive this, because nothing else runs on its own: a member does not re-upload their export at the moment a film is added. The retry sits outside the RSS sync and is independent of its outcome, including the 304 path that is the common case on a 10-minute poll. The two wait on different things — the RSS poll on the member's Letterboxd feed, this on the Jellyfin library. Gating it on a feed change would drain a backlog only for members still actively posting to Letterboxd, which is the opposite of who needs it. The library fingerprint is taken ONCE per tick, before the user loop. Per-user would repeat the query and, worse, let the first user with a backlog record the new value so everyone after them saw an unchanged library and skipped. It advances only after a pass completes, so a throw retries on the next tick rather than being swallowed by a stale stamp. SyncNow and the ZIP import double as the manual trigger, so a member who just added a film can pull its rating in without waiting. SyncNow is unthrottled — it is a deliberate click, not a timer — and the ZIP path reuses the lookup it already built, so it costs no extra library scan. Draining after an import also catches rows an EARLIER import parked whose film has since arrived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…154#25) Surfaces RetainUnmatchedLetterboxdRows in the plugin config page, following MirrorToNativeRating: the checkbox row in configPage.html plus the two widget.js lines that load and save it. Wording says what the member gets rather than naming the mechanism, and states both that it is off by default and that the waiting rows are stored on disk — the cost an admin is actually opting into. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…54#25) 23 tests over the two things that decide whether this feature works. The dedupe key, pinned from both sides: re-importing an export must not stack a second copy of a backlog, punctuation and accents must not queue a film twice, rewatches on different days must stay separate rows, and two watches on the same day must collapse to one. Plus a re-import refreshing a corrected rating while preserving FirstSeenAt, per-user isolation, removal, the cap, and that a corrupt file starts empty rather than taking plugin startup down with it. Durability gets its own test because the whole feature rests on it: the queue has to outlive the server, since the film it waits for may arrive months later. The matcher tests exist because every capture site branches on exactly one condition — MovieLookup.Find returned null — which makes Find the hinge the feature turns on. The failure they guard is quiet: a title that should match but does not gets queued forever, since no later retry can ever clear it. So WALL·E/WALL-E, Amélie/Amelie, "Matrix, The", ±1 year drift, and picking the right year among duplicate titles. Note for anyone running these locally: the projects target net9.0, and on a machine without a .NET 9 runtime installed `dotnet test` aborts with a runtime error that reads like a test failure. DOTNET_ROLL_FORWARD=Major runs them on a newer runtime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…154#25) "Sync now" said "Nothing new on Letterboxd right now." immediately after applying two ratings whose films had just been added to the library. The ratings did land — the message was simply blind to them. Both status builders computed their "did anything happen" total as imported + updated, which only covers what came from the RSS feed. The feed being idle is the normal case for a retry: what changed is the Jellyfin library, not Letterboxd. So a sync that resolved a backlog reported nothing, which reads as a failure right when the feature has done its job. Adds pendingResolved to both totals and to both result messages, as "applied N now in library". The field was already on LetterboxdImportResult and populated by SyncNow; only the UI was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Closes #25.
A Letterboxd import can only rate films the server already has. Every other row is counted as "unmatched" and dropped, so importing a large history into a smaller library silently loses most of it, with no way back except re-uploading the export later and hoping the film has arrived.
This keeps those rows and replays them as the library grows.
Behavior
Opt-in, off by default: Dashboard → Plugins → StarTrack → "Keep Letterboxd ratings for films not yet in the library." With it off, nothing changes — no queue file is written and the import behaves exactly as before.
With it on, rows that match nothing are parked in a per-user queue. Later syncs replay them, and each row is removed once it applies. Retry is also triggered by "Sync now" and after a ZIP import, so a member who just added a film can pull its rating in without waiting.
Design notes
Storage. A JSON queue at
data/InternalRating/letterboxd-pending.json, following the same shape asLetterboxdPushLedger(SemaphoreSlim+ atomic tmp/move). It is the inverse of that ledger — the ledger records work already done so it can be skipped, this records work not done so it can be repeated — and unlike the ledger it shrinks, because every row that finally matches is removed. Capped at 10k rows per user.I went with parsed rows rather than the "store the export ZIPs in an admin-configured directory" idea sketched in the issue: re-parsing whole exports every sync would also re-run watchlist/likes/diary as a side effect, and it needs a config surface (path validation, permissions, cleanup). The parsed queue holds only what actually failed. Happy to revisit if you'd prefer the other shape.
Dedupe key is
(kind, normalized title, year), plus the watched date for diary rows only. Rewatches are genuinely separate entries and collapsing them on title+year would keep just one; ratings deliberately exclude the date so a re-export with a shifted timestamp updates in place instead of queueing a second copy. Re-importing an export merges rather than stacking, per the issue.RSS is captured too, not just the CSVs. It matters more than the CSV paths: a CSV row can always be recovered by re-uploading the export, but an RSS entry is seen once,
lastSyncedGuidmoves past it, and it scrolls off the feed. Rating a film on Letterboxd the week before it lands in Jellyfin used to lose that rating permanently.Cost control. Replaying needs the movie lookup, which is a full
GetItemListplus aFile.Existsper item — too expensive for a task that ticks every 10 minutes.GetLibraryFingerprint()gates it: movie count plus newestDateCreatedfrom two indexed queries, compared against the previous tick. It is the local counterpart of the ETag the RSS poll already sends, used the same way, and here it is provably lossless rather than a heuristic — the only thing that can resolve a pending row is an item appearing, so an unmoved library means a retry cannot succeed. Steady state is one cheap query per tick.The retry sits outside the RSS sync and is independent of its outcome, including the 304 path. The two wait on different things: the RSS poll on the member's Letterboxd feed, this on the Jellyfin library. Gating it on a feed change would drain a backlog only for members still actively posting to Letterboxd, which is the opposite of who needs it.
Known gap, documented at the method: an in-place metadata edit that fixes a title moves neither part of the fingerprint, so it is not picked up until the next add or remove.
ItemSortByhas noDateLastSavedin 10.11, so catching it would mean materialising the library — the exact cost being avoided. Manual "Sync now" always forces a pass.Testing
Verified on a live 10.11 server (ZimaOS, Docker) against a real Letterboxd export — deliberately a 19-movie library, which is the issue's scenario at its most extreme:
That last round also caught the one bug in here: "Sync now" reported "Nothing new on Letterboxd right now" while ratings were visibly landing, because both status builders computed their total as
imported + updatedand never sawpendingResolved. An idle feed is the normal case for a retry, so that read as a failure exactly when the feature had done its job. Fixed in the final commit.23 new unit tests cover the dedupe key from both sides (re-import must not stack; rewatches on different days must stay separate; two watches the same day must collapse), persistence, the cap, corrupt-file recovery, and the title-matching boundary every capture site branches on.
Commits
Six, each building independently so the history bisects: storage + config → capture and replay → wiring → admin toggle → tests → the status-message fix.
🤖 Generated with Claude Code