Skip to content

Persist unmatched Letterboxd rows and retry them as the library grows (#25) - #26

Open
Tailes14 wants to merge 6 commits into
ZL154:mainfrom
Tailes14:feature/persistent-letterboxd-ratings
Open

Persist unmatched Letterboxd rows and retry them as the library grows (#25)#26
Tailes14 wants to merge 6 commits into
ZL154:mainfrom
Tailes14:feature/persistent-letterboxd-ratings

Conversation

@Tailes14

@Tailes14 Tailes14 commented Sep 10, 2026

Copy link
Copy Markdown

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 as LetterboxdPushLedger (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, 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.

Cost control. Replaying needs the movie lookup, which is a full GetItemList plus a File.Exists per item — too expensive for a task that ticks every 10 minutes. GetLibraryFingerprint() gates it: movie count plus newest DateCreated from 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. 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.

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:

  • 303 ratings rows → 6 matched, 297 unmatched → 297 queued, plus 155 watchlist, 242 likes, 296 diary. 990 rows total, matching the file exactly. All four capture sites fired, one write per CSV.
  • Added two films from the queue, scanned, hit "Sync now" → both ratings applied and their rows removed.
  • Confirmed with the setting off that no queue file is written.

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 + updated and never saw pendingResolved. 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

Tailes14 and others added 6 commits September 8, 2026 22:04
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>
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.

Persist unmatched Letterboxd ratings for future library matches

1 participant