# Jellyfin mixed-content library support — design

**Issue:** [#489](http://192.168.1.95:3000/timothy/ersatztv/issues/489)
**Blocked by:** [#488](http://192.168.1.95:3000/timothy/ersatztv/issues/488) (`JellyfinMusicVideoLibraryScanner` crashes on its first item)
**Related:** [#474](http://192.168.1.95:3000/timothy/ersatztv/issues/474) (music channels dead — the issue this arose from)
**Date:** 2026-07-20

## Problem

`JellyfinApiClient.Project()` maps a Jellyfin library's `CollectionType` onto a `LibraryMediaKind`
and returns `None` for anything it does not recognise:

```csharp
response.CollectionType?.ToLowerInvariant() switch
{
    "tvshows"     => … LibraryMediaKind.Shows …,
    "movies"      => … LibraryMediaKind.Movies …,
    "musicvideos" => … LibraryMediaKind.MusicVideos …,
    "boxsets"     => CacheCollectionLibraryId(response.ItemId),
    _             => None      // mixed lands here, with no log line
};
```

A library whose content type is **mixed** is therefore dropped silently. It never appears in
ErsatzTV and nothing explains why. On the live system that is two libraries:

| Jellyfin library | marker | path | contents |
|---|---|---|---|
| Music Videos | `mixed.collection` | `/data/music` | shows (`Top of the Pops`, `Old Grey Whistle Test`, `Soul Train`, `The Midnight Special`), concert movies (`Concert Films`, `Kraftwerk – Minimum Maximum`, `Underworld`), and genuine single-performance music videos |
| Standup | `mixed.collection` | `/data/standup` | a mix of shows and movies |

Both are genuinely mixed. `mixed` is the honest content type, not a mislabelling.

The existing workaround for `Standup` is local library 14 pointed straight at `/data/standup` and
typed `Movies`. It bypasses Jellyfin, scans the content a second time, models shows as movies, and
forfeits Jellyfin's metadata. `/data/music` never received even that treatment, which is #474.

## Goal

Ingest mixed Jellyfin libraries while keeping their content **segregated** from the main `Movies`
and `TV Shows` libraries.

The organising principle: **a library is a place.** One physical path ↔ one Jellyfin library ↔ one
ErsatzTV library. Its contents are heterogeneous. This replaces the implicit "a library is a media
kind" model.

Segregation falls out of that directly: music and standup content lives in its own libraries, so it
cannot leak into `Movies` or `TV Shows`.

## Non-goals

- **Local (filesystem) mixed libraries.** See "Scope" below — deliberately excluded.
- **Emby and Plex mixed libraries.** Neither has music-video support at all today; adding mixed
  support there is a separate, larger piece of work.
- **`Songs` and `Images` inside a mixed library.** Jellyfin's `music` collection type is already
  unsupported (`// TODO: ??? for music libraries`) and out of scope here.
- Retiring local library 14 (`Standup`). That becomes possible afterwards, but is its own change.

## Scope decision: Jellyfin-only, and why

This is the load-bearing choice in the design.

**Remotely, classification is authoritative.** `IJellyfinApi` already queries items by `parentId` +
`includeItemTypes`:

```csharp
GetMovieLibraryItems(…,      string includeItemTypes = "Movie",      …)
GetShowLibraryItems(…,       string includeItemTypes = "Series",     …)
GetMusicVideoLibraryItems(…, string includeItemTypes = "MusicVideo", …)
```

`parentId` is the library's `ItemId`. A mixed library can therefore be queried once per type, and
Jellyfin returns disjoint, authoritative sets. `JellyfinLibraryItemResponse` also carries a per-item
`Type` field, which `ProjectToCollectionMediaItem` already switches on for boxsets. There is no
inference and no guessing.

**Locally, the same approach is unsafe.** Every local video scanner shares
`LocalFolderScanner.VideoFileExtensions`, so pointing the movie scanner and the television scanner
at one folder tree means each claims the other's files. Worse, `LibraryFolder` rows are keyed by
`LibraryPathId` with no notion of kind, so two scanners over one path would thrash each other's
etags via `LibraryRepository.SetEtag` / `CleanEtagsForLibraryPath`, producing either perpetual full
rescans or skipped scans.

That hazard does not exist remotely: among the remote scanners, only
`JellyfinMusicVideoLibraryScanner` touches `LibraryFolder` at all.

Both mixed libraries on the live system are Jellyfin libraries, so this scope costs nothing against
the goal.

There is an existing precedent worth noting for the local case:
`LocalLibraryHandlerBase.AreSubPaths` already permits an `Images` library and an `OtherVideos`
library to share one physical directory. That is two libraries over one tree — the same etag
contention described above — and it is the closest existing analogue if local mixed support is ever
revisited.

## Enabling facts (verified, not assumed)

1. **No DB migration is required.** `MediaItem` is table-per-type with no discriminator column.
   `LibraryPathId` sits on the abstract base (`MediaItem.cs`) and every subclass inherits it;
   `LibraryPath.MediaItems` is `List<MediaItem>`. Heterogeneous items under one `LibraryPath` are
   already legal. `MediaItemRepository.GetAllTrashedItems` already `COALESCE`s across
   `MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId, ImageId, RemoteStreamId` for a single
   `LibraryPathId`.

2. **`MediaKind` is dispatch and presentation, not structure.** Scheduling, playout, collections,
   smart collections and playlists contain zero `MediaKind` references. Search indexing keys off the
   item's own subclass. The SPA's browse and collections screens use a per-item
   `LibraryBrowseMediaType`, not the library's kind. `MediaKind` is persisted on the base `Library`
   table only, with no unique constraint or index involving it.

3. **Reconciliation is type-scoped.** `MediaServerMovieLibraryScanner` trashes against
   `movieRepository.GetExistingMovies(library)` — scoped to the `Movie` table — and the television
   scanner follows the same `GetExisting*` pattern. Several scanners over one library therefore
   cannot cross-delete.

## Design

### Model

Add `LibraryMediaKind.Mixed = 8`.

`JellyfinApiClient.Project()` maps a null or `"mixed"` `CollectionType` to it, producing one
`JellyfinLibrary` exactly as the recognised types do. One Jellyfin library yields one ErsatzTV
library, preserving the path ↔ Jellyfin library ↔ ErsatzTV library correspondence.

### Dispatch

`SynchronizeJellyfinLibraryByIdHandler` gains a `Mixed` arm that runs the three existing Jellyfin
scanners in sequence against the same library:

```
LibraryMediaKind.Mixed => movie scanner, then television scanner, then music-video scanner
```

Each scanner issues its own `includeItemTypes` query and reconciles only its own type. **No new
scanner is written** — this is composition of three that already exist.

Sequential rather than parallel: they share a `TvContext` factory and the `EntityLocker`, and the
ordering keeps failure attribution simple. Throughput is not a concern at this library size.

### Error handling

Both `ScanLocalLibraryHandler` and `SynchronizeJellyfinLibraryByIdHandler` currently end their
dispatch switch with `_ => Unit.Default`, which returns **success** for an unhandled kind and stamps
`LastScan` as though a scan had run. This is precisely how a missing `Mixed` arm would hide, and it
is fixed as part of this work: an unhandled kind logs and returns a `BaseError`.

Within the `Mixed` arm, a failure in one scanner is reported but does not abort the remaining
scanners — a broken music-video scan should not prevent the movies and shows in the same library
from being ingested. The library's overall result is an error if any arm failed.

### Touch points

| Area | Change |
|---|---|
| `ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs` | add `Mixed = 8` |
| `ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs` | map null/`mixed` → `Mixed` |
| `ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs` | `Mixed` arm; remove silent-success default |
| `ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs` | remove silent-success default |
| `SynchronizeJellyfinShowByIdHandler.cs` | relax the `"is not a TV show library"` guard to accept `Mixed` |
| `ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs` | verify rediscovery cannot flip a `Mixed` library's kind |
| `web/src/screens/LibrariesScreen.tsx` | icon + label for `Mixed` |
| `web/src/screens/LocalLibraryEditScreen.tsx` | `MEDIA_KIND_OPTIONS` — `Mixed` must **not** be creatable for a local library |
| `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts` | regenerate |
| `docs/decisions.md` | record the decision and the Jellyfin-only scope |

Untouched: scheduling, playout, collections, smart collections, search, browse.

### A note on the SPA

`LibraryMediaKind` is a generated enum shared by local and remote libraries, so adding `Mixed`
exposes it to the local-library create screen, where it is meaningless. The media-kind select must
exclude it. `RemoteLibrariesEditScreen` keys its drafts on `(name, mediaKind)`, which continues to
work unchanged.

## Testing

**Unit — `JellyfinApiClientTests`.** A `mixed` `CollectionType`, and a null one, each project to a
`JellyfinLibrary` with `MediaKind = Mixed`. An unrecognised type still yields `None`.

**Unit — dispatch.** `SynchronizeJellyfinLibraryByIdHandlerTests` (the file already exists): a
`Mixed` library invokes all three scanners; an unhandled kind returns an error rather than success.

**Regression — #488.** Covered by that issue, but this feature depends on it: a Jellyfin
music-video scan must complete against a `LibraryPath` whose `LibraryFolders` navigation is not
eager-loaded.

**Cross-type deletion.** The critical test. Seed a single library with a movie, a show and a music
video; run the full `Mixed` scan with one type absent from the Jellyfin response; assert only that
type is flagged missing and the others are untouched. Prove non-vacuous by temporarily widening a
reconciliation query and watching the test fail.

**Live E2E.** Re-type the live `Music Videos` library back to `mixed`, scan, and confirm movies,
shows and music videos all land in that one library with nothing appearing in `Movies` or
`TV Shows`. This is a write-path scanner change, so live E2E is required per `docs/e2e-local.md`.

## Open risk

`JellyfinMusicVideoLibraryScanner`'s reconciliation has **not** been traced. It is the odd one out —
standalone, not derived from `MediaServer*LibraryScanner`, with no `ItemId`/`Etag` to key on, so it
identifies items by path-replaced local path. It is the one place cross-type deletion could still
hide, and it must be traced before the `Mixed` arm is trusted. This is a precondition of the
cross-type deletion test above, not a follow-up.

## Rollout

1. Fix #488 (blocking).
2. Land this feature.
3. Re-type the live `Music Videos` Jellyfin library back to `mixed`, reverting the provisional
   `musicvideos` re-type made on 2026-07-20.
4. Re-enable `shouldSyncItems` on that library and scan.
5. Afterwards, local library 14 (`Standup` → `/data/standup`) becomes retirable in favour of the real
   Jellyfin `Standup` library. Separate change.
