---
key: scan.musicvideo-server-identity
title: 2026-07-25 — Music videos carry a per-library server identity; reconciliation is an itemId diff + soft trash (#496)
status: active
since: '2026-07-25'
supersedes: scan.musicvideo-reconciliation@2026-07-20
superseded-by: none
rule: '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.'
signals: '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'
mechanics: '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.
