Files
ersatztv/docs/decisions/records/scan/musicvideo-server-identity.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

7.6 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
scan.musicvideo-server-identity 2026-07-25 — Music videos carry a per-library server identity; reconciliation is an itemId diff + soft trash (#496) active 2026-07-25 scan.musicvideo-reconciliation@2026-07-20 none Jellyfin music videos carry a per-library server identity (`JellyfinMusicVideo : MusicVideo` with `ItemId`/`Etag`, TPT table + ItemId index), so `JellyfinMusicVideoLibraryScanner` folds onto a shared `MediaServerMusicVideoLibraryScanner` base that diffs the **server item id** and soft-trashes (`FlagFileNotFound`) instead of diffing local paths and hard-deleting. Rows predating the identity are **adopted in place** — the identity row is inserted against the same `MediaItem` id, scoped to the scanned library's own `LibraryPath` — never deleted and re-added. music-video server identity, JellyfinMusicVideo ItemId/Etag, itemId diff, soft FileNotFound trash, adoption of pre-identity rows, cross-library false-trash, path-replaced PathHash · paths: `JellyfinMusicVideo`, `JellyfinMusicVideoRepository`, `IMediaServerMusicVideoRepository`, `MediaServerMusicVideoLibraryScanner`, `JellyfinMusicVideoLibraryScanner` · issues: #496, #494, #477, #484, #488, #497, #500, #606 dual-provider migration `Add_JellyfinMusicVideo`; `GetOrAdd` takes `localPath` explicitly and never reads the projection's path; adoption probe joins `MediaFile.PathHash` + `NOT EXISTS (JellyfinMusicVideo)` filtered to the library's `LibraryPath`; `GetByItemId` filters `LibraryPath.LibraryId`; legacy (identity-less) rows reconciled by `GetExistingLegacyMusicVideoPaths` + `FlagFileNotFoundByPaths` and counted into the #477 guard; `ScanLibrary_Should_Adopt_Using_The_PathReplaced_Local_Path`, `ScanLibrary_Should_Adopt_PreExisting_MusicVideo_Preserving_Identity_And_Collections`, `ScanLibrary_Should_Not_Adopt_A_MusicVideo_Owned_By_Another_LibraryPath`, `ScanLibrary_Should_Not_Resolve_An_ItemId_Owned_By_Another_Library`, `ScanLibrary_Should_Flag_A_Legacy_Row_The_Server_No_Longer_Reports`

#494 gave music videos a trash sweep but had to key it on (LibraryPathId, path) and hard-delete, because music videos carried no server identity. That left the known limitation this issue is named for: a file served by two libraries with overlapping local paths is one row owned by whichever library scanned it first, and that owner's sweep destroyed the row the other library still served. This is the deferred "option 2".

  • Identity is the server item id, per library. JellyfinMusicVideo mirrors JellyfinMovie exactly (TPT table, ItemId/Etag varchar(36), index on ItemId). GetExistingMusicVideos and FlagFileNotFound both join LibraryPath.LibraryId, so the existing-set and the flag set are scoped to the scanning library — one library's sweep can no longer resolve, let alone remove, another library's row.
  • Soft trash replaces hard delete. The sweep now flags FileNotFound (State = 1) like the movie/TV/other-video base scanners. The row survives, so collection membership, playout references and artwork survive with it, and removal is EmptyTrash-governed and reversible. The visible consequence is that DeleteEmptyArtists no longer fires from a sweep — a trashed music video still belongs to its artist.
  • Pre-identity rows are ADOPTED, not re-added. Every music video on an existing install has no JellyfinMusicVideo row and so can never be found by item id. Deleting and re-adding would mint a new MediaItem id and silently drop CollectionItem membership; MediaItemRepository.MediaFileAlreadyExists would in fact block the re-add outright and the item would error on every scan forever. So GetOrAdd probes for an identity-less MusicVideo at the same PathHash within the scanned library's own LibraryPath and inserts the identity row against that same id. The scoping is the point: the local MusicVideoFolderScanner writes the same MusicVideo table, and a local (or second-library) row must never be hijacked into this library's identity.
  • The adopted row is written with an empty etag so the ordinary "etag changed ⇒ refresh" path picks it up once, rather than needing a second adoption-specific update path.
  • The path-replaced local path is the ONLY path this seam may match on or store. GetOrAdd takes localPath as an explicit argument; the item it also receives still carries the path the media server reported, and on any install with path replacements configured those are different strings. An earlier cut of this change let the repository derive the path from the projection, which hashed the wrong string, missed the row adoption was meant to find, slipped past MediaFileAlreadyExists (which hashes that same wrong string), and inserted a duplicate row under a server-side path while the original collection-linked row stayed identity-less forever. The identity path-replacement stub in the test harness hid it; the regression test now uses a mapping that genuinely replaces.
  • Identity lookup is scoped to the scanning library, not just the item id. Two media sources can present the same item id (a cloned Jellyfin database); a global match would let one library silently repoint another library's row.
  • Legacy (identity-less) rows are reconciled by local path until adopted. A row that predates the identity AND is already gone from the server is never adopted — adoption only ever runs for an incoming item — and carries no identity, so the itemId diff cannot see it either. Without a residual path diff it would sit Normal and schedulable forever, strictly worse than the hard delete it replaced. Legacy rows are also counted into the #477 empty-fetch guard: on the first scan after this ships they ARE the whole library, so a guard that counted only identity rows would see "0 existing" and sweep all of them on a transient empty fetch.
  • Both sweeps sit behind #484's projection-failure refusal. ShouldFlagMissing's projectionFailureCount is an OPTIONAL parameter defaulting to 0, so a scanner that never passes it compiles and silently opts out of the protection — which is what this scanner did until the counter was threaded through (MediaServerProjectionFailureCounter, one per enumeration, never a field on the singleton api client). The single guard call covers the legacy path diff too, which is if anything more exposed: a legacy row has no etag to fall back on. #484's own music-video test asserted the pre-#496 hard delete (FindMusicVideoPaths/DeleteByPath) and could not survive this change; it is replaced by ScanLibrary_Should_Not_Sweep_When_The_Projection_Reported_Failures (identity sweep) and ..._Should_Not_Sweep_Legacy_Rows_When_The_Projection_Reported_Failures (legacy sweep). Their substitute records failures during enumeration, not when the enumerable is handed out — an eager substitute would keep passing if a refactor snapshotted Count before the enumeration completed, which is precisely the regression the guard exists to prevent.
  • Scope honesty: this is parity, not a total fix. One file path is still one MediaItem row globally (MediaFileAlreadyExists is a global path-hash guard), so a second library serving the same file still gets no row of its own — exactly as for movies/TV. What changes is that the first library's sweep now flags rather than destroys that shared row. The unrecoverable data loss is gone; the shared-row limitation is a whole-app property, not a music-video one, and is tracked separately as #606. #496's Done-when was amended to this parity wording rather than ticked as literally written.