Review finding 1 (blocking). ScanSeasons' FlagFileNotFoundSeasons and ScanEpisodes'
FlagFileNotFoundEpisodes had no guard at all — neither #477's nor #484's — so ProjectToSeason /
ProjectToEpisode returning Failed() was computed and discarded.
#477 scoped those out because "the blast radius is one show's seasons / one season's episodes",
which holds for a per-parent EMPTY fetch but not for a projection failure: that is systematic by
construction. One bad code path fires on every parent, so every season enumerates zero episodes,
existing.Except([]) is the whole episode library, and EmptyTrashHandler deletes it permanently.
Threads the counter into GetSeasonLibraryItems / GetEpisodeLibraryItems(WithoutPeople) for
Jellyfin and Emby using the same optional-trailing-param shape, and guards both sweeps with
MediaServerReconciliationGuard.ShouldFlagMissingDescendants — the same class and the same private
failure predicate as ShouldFlagMissing, deliberately WITHOUT #477's empty-fetch branch so
per-parent empty behaviour (and #476's cascade, which depends on it) is unchanged.
Also from the review:
- finding 3: tests now pin the same-instance JOIN at every level (movie, show, season, episode,
music video) by driving the real ScanLibrary entry point and recording the failure from inside
the enumeration, so a refactor handing the api client a fresh counter goes red.
- finding 4: the missing-library Failed() branch is documented as defensive and unreachable.
- finding 2: the mass-Skip residual (Emby's response-shape-dependent MediaSources guard, Plex's
pre-projection filter) is stated as a known limitation in the decision record.
- finding 5: the log-contract change (only the #484 message when both refusals apply) is noted.
fixes#484
Extends MediaServerReconciliationGuard (#477) with a second deterministic refusal: when the
enumeration that produced the incoming set silently dropped items whose projection THREW, the
file-not-found sweep is refused. A dropped item the server did return is indistinguishable
from a deletion at the reconcile step, so a projection regression could otherwise mass-flag a
healthy library FileNotFound (which EmptyTrash then deletes permanently).
Deliberate guard-clause skips (STRM files, virtual items, unsupported types) are explicitly NOT
failures and never suppress a sweep — counting them would permanently disable reconciliation for
any library holding a single STRM file.
The ratio / missing-fraction threshold is REJECTED, not deferred: it is a two-sided heuristic
with no tunable default and no telemetry, and the failure it approximates is exactly observable
via the projection-failure count (a genuine bulk deletion produces zero failures).
Seam is deliberately narrow — the private ProjectTo* contract inside each api client changed from
Option<T> to MediaServerProjectionResult<T> (projected/skipped/failed), the paged helper counts
IsFailure in one place, and the scanner reads it through an optional trailing
MediaServerProjectionFailureCounter on only the five library-level methods that feed a sweep.
The counter is per-enumeration state created by the scanner, never a field on an api client.
fixes#484
The remove-stale + add-new reconcile idiom materializes its add set with
.ToList() BEFORE the loop mutates the existing collection, so the add filter
(`incoming.All(x2 => x2.Name != x.Name)`) is evaluated against a snapshot. Two
identically-named incoming entries whose name is not yet on the existing item
therefore BOTH passed the filter and BOTH inserted — a duplicate row.
Deduplicate the incoming set on the same key the filter compares (Name; Guid
for Guids), in both copies of the idiom:
- PlexMovieLibraryScanner.UpdateMetadata (the original) — genres, studios,
actors, directors, writers, guids, tags.
- JellyfinMusicVideoLibraryScanner.Reconcile{Genres,Tags,Studios,Artists}
(added in #497, mirrors the Plex pattern verbatim).
Plex ACTORS are the exception and get an artwork-preferring dedup hoisted out
and shared with the remove filter, because that filter is keyed on
(Name, artwork-presence) — it is the mechanism that drops an artwork-less actor
so the add loop can re-add it WITH artwork. A bare DistinctBy(a => a.Name)
there keeps the FIRST duplicate, so Plex listing the artwork-less copy first
discarded the artwork; worse, the remove filter would still see the
artwork-less duplicate, making its upgrade clause false, so the stale row was
never removed and the artwork never arrived on ANY later scan either. Actor
also carries Role/Order, which first-wins would silently drop too. Caught by
the cold review of the first version of this commit.
For the Jellyfin scanner the dedup sits at the incoming-list declaration, which
also covers the remove filter — safe because all four of those filters only ask
"is this name present at all", an answer duplicates cannot change.
Tests: duplicate-collapse for both paths, the two Actors cases above, and a
POSITIVE CONTROL proving distinct entries are still all added and stale ones
still removed (without it, a mis-keyed dedup that collapsed genuinely different
entries would pass every other assertion). Each proven non-vacuous.
The Plex tests drive the protected UpdateMetadata through a minimal test-only
subclass, as MediaServerMovieLibraryScannerTests already does.
Low likelihood in practice (a media server emitting two identically-named
genres for one item is unusual); this is defensive, with no observed occurrence.
The same idiom is copied into 8 further scanners/repositories that this change
deliberately does not touch (the issue scoped it to two paths) — filed as #600
so the class of bug is tracked rather than silently left in the majority of its
instances. The dedup rule is recorded under scan.musicvideo-reconciliation.
fixes#500
Jellyfin libraries typed `mixed` were dropped by JellyfinApiClient.Project's
`_ => None` with no log line, so music and standup content could not be
ingested without a local-library workaround that bypassed Jellyfin entirely.
Adds LibraryMediaKind.Mixed, maps "mixed"/absent/blank CollectionType onto it,
and gives SynchronizeJellyfinLibraryByIdHandler a Mixed arm composing the three
existing per-kind scanners. Jellyfin classifies items server-side via
includeItemTypes, so the passes see disjoint sets; reconciliation is type-scoped
and cannot cross-delete. No new scanner and no DB migration -- MediaItem is TPT
keyed on LibraryPathId, so heterogeneous contents were already legal.
Segregation falls out of the model: a library is a place (one path <-> one
Jellyfin library <-> one ErsatzTV library), so music/standup cannot leak into
Movies or TV Shows.
Also removes the silent-success `_ => Unit.Default` from both scanner
dispatchers, which returned Right for an unhandled kind and stamped LastScan as
though a scan had run, and rejects Mixed for local libraries at the API.
Deliberately Jellyfin-only: local scanners share one video extension list and
would claim each other's files, and LibraryFolder etags are keyed by
LibraryPathId with no notion of kind.
Verified by live E2E against a real Jellyfin, including the interaction with
#494's reconciliation sweep. Four cold review rounds, all MERGEABLE.
fixes#489
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
A successful fetch returning zero items made existing.Except([]) flag the ENTIRE
library FileNotFound in one scan — feeding EmptyTrashHandler's permanent delete
and emptying every affected collection (dead channels). Add a shared
MediaServerReconciliationGuard that skips (and logs a Warning) the sweep when
incoming==0 while items exist, wired into the three library-level sweeps
(Television shows / Movie / OtherVideo).
An empty incoming set is indistinguishable at scan time from a mid-restore /
emptied-upstream error (both report a zero total), so this deliberately overrides
#476's degenerate "last item removed => empty incoming => flag" case. #476's
cascade still fires for partial deletions (survivors present); its characterization
test moves from an empty incoming to a survivor+removed partial-deletion case.
Tests: policy table (MediaServerReconciliationGuardTests) + per-scanner integration
proving the wiring (empty incoming + non-empty existing flags/reindexes nothing).
Proven non-vacuous by neutralizing the guard. Nested TV season/episode sweeps left
unguarded (bounded blast radius); ratio-threshold + projection-failure detection
deferred to a follow-up. docs/decisions.md updated.
Fixes#477
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The media-server television scanner reconciles removed items at three levels
via existing.Except(incoming), but the season and episode sweeps live inside
the per-parent loops (ScanSeasons inside the show loop, ScanEpisodes inside the
season loop). Those loops only iterate parents present in the incoming list, so
a show (or season) that is gone from the media server is never visited and its
descendants are never swept — they keep their last state. On Jellyfin/Emby that
is RemoteOnly, which PlayoutBuilder does NOT skip even with PlayoutSkipMissingItems
on, so every orphaned episode keeps getting scheduled as a guaranteed tune-in
failure (the #473 reproduction; 717 stale prod rows across 10 removed shows).
Fix: cascade the flag by parent MediaItem.Id. Two provider-agnostic repo helpers
(Season.ShowId / Episode.SeasonId are on the base tables) flag descendants and the
scanner drives them after each parent sweep — show → seasons → episodes, and
season → episodes for the show-present case. Shared abstract base fixes
Jellyfin/Plex/Emby at once; Movie/OtherVideo are flat and have no such gap.
Tests: a Scanner.Tests case asserts the scanner cascades through the (substituted)
repository (non-vacuous — fails if the cascade calls are removed), and Integration
tests exercise the real cascade SQL against the schema, proving it flags only the
targeted subtree and no-ops on empty input.
fixes#476
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-review of the previous fix commit (986ccfaf, verdict MERGEABLE)
raised one fair hit and one claim that did not survive checking.
Fair hit — that commit's message asserted "LogError is an extension
method that NSubstitute can't cleanly verify" as the reason for adding
no test. That is FALSE, and this repo disproves it:
ErsatzTV.Tests' ReleaseMemoryHandlerTests.ShouldHaveLogged asserts on
ILogger via ReceivedCalls(), which works precisely because the LogError
extension dispatches to the substituted ILogger.Log. Reusing that idiom
here costs 4 lines, so pin the log the previous commit added. Verified
non-vacuous: asserting a message the handler never logs fails the test.
Not applied — the same review called the row's "1471 tests" misleading
on the grounds that TranscodingTests is [Explicit] and contributes most
of that count. TranscodingTests is indeed [Explicit], but filtering it
out yields exactly 1471, so 1471 is already the runnable count and the
row was accurate. Kept the number; documented TranscodingTests as
[Explicit]/opt-in instead, since it was a genuine omission from a table
that claims to be authoritative.
Also rewrap the verification-gate paragraph the previous commit left
over-long, and name the two always-run projects instead of "both".
Deferred (filed separately): a canceled local scan now logs at ERROR
per path via this log. It mirrors the remote handlers exactly, so
diverging here would be the inconsistency, not the fix.
Local libraries permanently showed "Never scanned" in the SPA libraries
hub regardless of successful scans, even as item counts updated.
Root cause: ScanLocalLibraryHandler wrote only the path-level
LibraryPath.LastScan (which gates the per-path refresh interval) and
never the library-level Library.LastScan. The read API
(GetAllMediaSourcesForApiHandler) populates the hub's scan-time badge
from Library.LastScan, so that value stayed null forever. The three
remote scanners (Jellyfin/Emby/Plex) already set the library-level
value; only the local scanner did not. Both sides predate #202 — the
SPA hub merely made the missing value visible.
Mirror the remote scanners' semantics: record the library-level scan
time only when the scan actually ran and every path that ran succeeded,
so a skipped (unforced, interval not elapsed) or partially-failed scan
does not claim a successful scan time.
Also de-BOM the touched handler per the fix-as-you-touch charset gate
(#311).
fixes#264
Move all 154 PackageReference versions (96 distinct packages) out of the 14
project files into a single central Directory.Packages.props with
ManagePackageVersionsCentrally=true. No version changes — every package was
already pinned identically across projects (no conflicts detected), so this is a
pure relocation: updates become one-line and cross-project version drift is
structurally impossible.
Also copy Directory.Packages.props into the Docker image build before restore:
with CPM the csproj carry no versions, so the image's `dotnet restore` fails
without the central manifest (verified: NU1015 across every project).
Restore + Release build verified locally, plus a simulation of the image's
restore layer under linux-x64 (0 errors; only the pre-existing
NCalcSync/SQLitePCLRaw advisories remain, demoted to warnings, tracked in #8).
Part of #14.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* add new fields to database
* update editor
* audio and video normalization settings appear to work
* implement optional color normalization
* fix transcoding tests
* update changelog
* fix nvenc playback when color metadata changes mid-stream
* update dependencies (needed to fix unit test runner)
* limit noautoscale to when it's not already present
* fix effective block tests running on github
* update dependencies
* pass tz again
* use tzconvert for time zones in tests
* temporary logging
* maybe fix
* test cleanup
* handle artwork timeouts so they aren't reported
* catch some more cancellation errors
* add free space validation on startup
* add downgrade health check
* update dependencies
* move watermark options into watermark selector
* fix graphics engine overlay performance
* more refactoring
* add some tests for existing watermark selector behavior
* remove extra ffprobe call on all watermarks
* remove a bunch of unused code; add failing tests
* implement new watermark selection
* add tests for new selector
* probably sufficient (though verbose) test coverage
* more tests
* remove some unused code
* simplify watermark selection
* remove old selection code and tests
* more tests