Files
plezy/test/media/media_item_test.dart
T
edde746 05fd622968 feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.

Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.

Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
  `/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
  forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
  the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
  ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.

Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
  from list rows unless named in `Fields`, which would otherwise strip the year
  and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
  `EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
  detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
  requested. Without it every recency-ordered surface silently degrades to
  library-add time, and `JellyfinApiCache.applyWatchState` stamps
  `DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
  the cached play time of everything it walked.

Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
  returns nothing under every parameter combination tried. The shelf is
  therefore reconstructed from a played-episode recency scan plus one
  `/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
  that covers the scan as well — per-request timeouts cannot bound the pass
  because `MediaServerHttpClient` times the connect and receive phases
  independently. Rows are stamped with their series' newest play from the same
  response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
  filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
  ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
  unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
  episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
  makes Continue Watching removal a real capability.

Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
  `PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
  reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
  plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
  for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
  and intro/credit markers fall back to chapter names.

Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
2026-08-05 06:09:26 +02:00

737 lines
24 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_browser_dialect.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_rating.dart';
import 'package:plezy/media/media_role.dart';
import 'package:plezy/media/media_version.dart';
import '../test_helpers/media_items.dart';
/// Backend-agnostic [MediaItem] tests. Existing coverage is split between
/// `plex_mappers_test` and `jellyfin_mappers_test` — those exercise the
/// JSON mappers but never the neutral model itself. If a mapper is removed
/// or refactored these tests still pin the model contract: equality,
/// copyWith, watch-state derived getters.
MediaItem _movie({
String id = 'm1',
String? title = 'Movie',
int? year,
int? viewCount,
int? leafCount,
int? viewedLeafCount,
int? durationMs,
int? viewOffsetMs,
String? artPath,
List<String>? backdropPaths,
String? backgroundSquarePath,
MediaBackend backend = MediaBackend.plex,
}) => testMediaItem(
id: id,
backend: backend,
kind: MediaKind.movie,
title: title,
year: year,
viewCount: viewCount,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
durationMs: durationMs,
viewOffsetMs: viewOffsetMs,
artPath: artPath,
backdropPaths: backdropPaths,
backgroundSquarePath: backgroundSquarePath,
serverId: 's1',
);
void main() {
group('MediaItem.isWatched', () {
test('movie with viewCount > 0 is watched', () {
expect(_movie(viewCount: 1).isWatched, isTrue);
expect(_movie(viewCount: 5).isWatched, isTrue);
});
test('movie with viewCount 0 or null is unwatched', () {
expect(_movie(viewCount: 0).isWatched, isFalse);
expect(_movie(viewCount: null).isWatched, isFalse);
});
test('show with all leaves watched is watched', () {
final show = testMediaItem(
id: 's',
backend: MediaBackend.plex,
kind: MediaKind.show,
leafCount: 10,
viewedLeafCount: 10,
serverId: 's1',
);
expect(show.isWatched, isTrue);
});
test('show with viewedLeafCount > leafCount is still watched (defensive)', () {
final show = testMediaItem(
id: 's',
backend: MediaBackend.plex,
kind: MediaKind.show,
leafCount: 10,
viewedLeafCount: 11,
serverId: 's1',
);
expect(show.isWatched, isTrue);
expect(show.unwatchedCount, 0);
});
test('show with no leaf info falls back to viewCount', () {
final show = testMediaItem(
id: 's',
backend: MediaBackend.plex,
kind: MediaKind.show,
viewCount: 1,
serverId: 's1',
);
expect(show.isWatched, isTrue);
});
test('leaf media ignores aggregate leaf counts', () {
expect(_movie(viewCount: 0, leafCount: 1, viewedLeafCount: 1).isWatched, isFalse);
expect(_movie(viewCount: 1, leafCount: 1, viewedLeafCount: 0).isWatched, isTrue);
});
test('container media uses aggregate leaf counts', () {
final album = testMediaItem(
id: 'a',
backend: MediaBackend.plex,
kind: MediaKind.album,
viewCount: 0,
leafCount: 8,
viewedLeafCount: 8,
serverId: 's1',
);
expect(album.isWatched, isTrue);
});
test('every media kind has explicit leaf or container watch semantics', () {
const containerKinds = [
MediaKind.show,
MediaKind.season,
MediaKind.artist,
MediaKind.album,
MediaKind.collection,
MediaKind.playlist,
MediaKind.folder,
];
expect(MediaKind.values.where((kind) => kind.usesLeafWatchCounts), containerKinds);
for (final kind in MediaKind.values) {
final item = testMediaItem(
id: kind.id,
backend: MediaBackend.plex,
kind: kind,
viewCount: 0,
leafCount: 2,
viewedLeafCount: 2,
serverId: 's1',
);
final usesLeaves = containerKinds.contains(kind);
expect(item.isWatched, usesLeaves, reason: '${kind.id} watched semantics');
expect(item.isPartiallyWatched, isFalse, reason: '${kind.id} partial semantics');
expect(item.unwatchedCount, usesLeaves ? 0 : null, reason: '${kind.id} unwatched count semantics');
}
});
test('zero container leaf total falls back to viewCount', () {
final show = testMediaItem(
id: 's',
backend: MediaBackend.plex,
kind: MediaKind.show,
viewCount: 1,
leafCount: 0,
viewedLeafCount: 0,
serverId: 's1',
);
expect(show.isWatched, isTrue);
});
});
group('MediaItem.heroArtCandidates', () {
test('near-square containers prefer square art before wide cover art', () {
final movie = _movie(artPath: '/art', backgroundSquarePath: '/square');
expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/art']);
});
test('near-square containers fall back to wide cover art when square art is missing', () {
final movie = _movie(artPath: '/art');
expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/art']);
});
test('wide containers prefer wide cover art before square art', () {
final movie = _movie(artPath: '/art', backgroundSquarePath: '/square');
expect(movie.heroArtCandidates(containerAspectRatio: 16 / 9), ['/art', '/square']);
});
test('episodes prefer show art before episode art for wide hero containers', () {
final episode = testMediaItem(
id: 'e1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode',
grandparentTitle: 'Show',
grandparentArtPath: '/show-art',
artPath: '/episode-art',
backgroundSquarePath: '/square',
serverId: 's1',
);
expect(episode.heroArtCandidates(containerAspectRatio: 16 / 9), ['/show-art', '/episode-art', '/square']);
expect(episode.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/show-art', '/episode-art']);
});
test('Jellyfin movies expose every backdrop in display order', () {
final movie = _movie(
backend: MediaBackend.jellyfin,
artPath: '/art-0',
backdropPaths: ['/art-0', '/art-1', '/art-2'],
backgroundSquarePath: '/square',
);
expect(movie.heroBackdropPaths, ['/art-0', '/art-1', '/art-2']);
expect(movie.heroArtCandidates(containerAspectRatio: 16 / 9), ['/art-0', '/art-1', '/art-2', '/square']);
});
test('episodes prefer inherited backdrops over their own art', () {
final episode = testMediaItem(
id: 'e-multi',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
artPath: '/episode-0',
backdropPaths: ['/episode-0', '/episode-1'],
grandparentArtPath: '/show-0',
grandparentBackdropPaths: ['/show-0', '/show-1', '/show-2'],
);
expect(episode.heroBackdropPaths, ['/show-0', '/show-1', '/show-2']);
expect(episode.heroArtCandidates(containerAspectRatio: 16 / 9), [
'/show-0',
'/show-1',
'/show-2',
'/episode-0',
'/episode-1',
]);
});
test('legacy scalar art remains a single static backdrop', () {
final movie = _movie(artPath: '/legacy-art');
expect(movie.resolvedBackdropPaths, ['/legacy-art']);
expect(movie.heroBackdropPaths, ['/legacy-art']);
});
});
group('MediaItem.heroRotationPaths', () {
/// The order `CyclingMediaBackdrop` attempts paths as each one fails:
/// every rotating path, then the fallbacks it is not already rotating.
/// Only the head of this list is ever displayed by a healthy server.
List<String> displayOrder(MediaItem item, double aspect) {
final rotation = item.heroRotationPaths(containerAspectRatio: aspect);
final candidates = item.heroArtCandidates(containerAspectRatio: aspect);
return [...rotation, ...candidates.where((path) => !rotation.contains(path))];
}
test('near-square containers hold on square art instead of rotating backdrops', () {
final movie = _movie(
backend: MediaBackend.jellyfin,
artPath: '/art-0',
backdropPaths: ['/art-0', '/art-1'],
backgroundSquarePath: '/square',
);
expect(movie.heroRotationPaths(containerAspectRatio: 1.0), ['/square']);
});
test('near-square containers rotate backdrops when there is no square art', () {
final movie = _movie(backend: MediaBackend.jellyfin, artPath: '/art-0', backdropPaths: ['/art-0', '/art-1']);
expect(movie.heroRotationPaths(containerAspectRatio: 1.0), ['/art-0', '/art-1']);
});
test('wide containers rotate backdrops and leave square art behind them', () {
final movie = _movie(
backend: MediaBackend.jellyfin,
artPath: '/art-0',
backdropPaths: ['/art-0', '/art-1'],
backgroundSquarePath: '/square',
);
expect(movie.heroRotationPaths(containerAspectRatio: 16 / 9), ['/art-0', '/art-1']);
});
test('rotation before fallback reproduces the candidate order at every aspect', () {
final episode = testMediaItem(
id: 'e-order',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
artPath: '/episode-0',
backdropPaths: ['/episode-0', '/episode-1'],
grandparentArtPath: '/show-0',
grandparentBackdropPaths: ['/show-0', '/show-1'],
backgroundSquarePath: '/square',
serverId: 's1',
);
for (final aspect in [0.75, 1.0, 1.38, 1.39, 16 / 9, 2.4]) {
expect(
displayOrder(episode, aspect),
episode.heroArtCandidates(containerAspectRatio: aspect),
reason: 'aspect $aspect',
);
}
});
});
group('MediaItem.isPartiallyWatched', () {
test('show with some leaves watched is partially watched', () {
final show = testMediaItem(
id: 's',
backend: MediaBackend.plex,
kind: MediaKind.show,
leafCount: 10,
viewedLeafCount: 3,
serverId: 's1',
);
expect(show.isPartiallyWatched, isTrue);
});
test('season progress uses direct episode count only when the leaf total is absent', () {
final season = testMediaItem(
id: 'season',
backend: MediaBackend.jellyfin,
kind: MediaKind.season,
childCount: 8,
viewedLeafCount: 3,
);
expect(season.leafWatchTotal, 8);
expect(season.leafWatchFraction, 3 / 8);
expect(season.isPartiallyWatched, isTrue);
});
test('show with zero leaves watched is NOT partially watched', () {
final show = testMediaItem(
id: 's',
backend: MediaBackend.plex,
kind: MediaKind.show,
leafCount: 10,
viewedLeafCount: 0,
serverId: 's1',
);
expect(show.isPartiallyWatched, isFalse);
});
test('show with all leaves watched is NOT partially watched', () {
final show = testMediaItem(
id: 's',
backend: MediaBackend.plex,
kind: MediaKind.show,
leafCount: 10,
viewedLeafCount: 10,
serverId: 's1',
);
expect(show.isPartiallyWatched, isFalse);
});
test('aggregate progress clamps contradictory counts', () {
final overReported = testMediaItem(
id: 'show',
backend: MediaBackend.plex,
kind: MediaKind.show,
leafCount: 10,
viewedLeafCount: 11,
);
final negative = overReported.copyWith(viewedLeafCount: -1);
expect(overReported.leafWatchFraction, 1);
expect(negative.leafWatchFraction, 0);
});
test('movie without leaf info is NOT partially watched (concept doesn\'t apply)', () {
expect(_movie(viewCount: 0).isPartiallyWatched, isFalse);
expect(_movie(viewCount: 1).isPartiallyWatched, isFalse);
});
});
group('MediaItem watch-state normalization', () {
test('leaf state ignores and clears stale aggregate fields', () {
final movie = testMediaItem(
id: 'm',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
viewCount: 0,
leafCount: 3,
viewedLeafCount: 3,
raw: {
'UserData': {'UnplayedItemCount': '0'},
},
);
expect(movie.isWatched, isFalse);
expect(movie.isPartiallyWatched, isFalse);
expect(movie.unwatchedCount, isNull);
expect(movie.withWatchedFlag(false).viewedLeafCount, isNull);
expect(movie.withWatchedFlag(true).isWatched, isTrue);
});
test('container mutations update the aggregate and item flags together', () {
final album = testMediaItem(
id: 'a',
backend: MediaBackend.plex,
kind: MediaKind.album,
viewCount: 0,
leafCount: 8,
viewedLeafCount: 3,
);
final watched = album.withWatchedFlag(true);
expect(watched.viewCount, 1);
expect(watched.viewedLeafCount, 8);
expect(watched.isWatched, isTrue);
final unwatched = watched.withWatchedFlag(false);
expect(unwatched.viewCount, 0);
expect(unwatched.viewedLeafCount, 0);
expect(unwatched.isWatched, isFalse);
});
test('container unwatched counts are clamped and tolerate string API values', () {
final overReported = testMediaItem(
id: 's1',
backend: MediaBackend.plex,
kind: MediaKind.show,
leafCount: 10,
viewedLeafCount: 11,
);
final slimJellyfin = testMediaItem(
id: 's2',
backend: MediaBackend.jellyfin,
kind: MediaKind.show,
raw: {
'UserData': {'UnplayedItemCount': '4'},
},
);
expect(overReported.unwatchedCount, 0);
expect(slimJellyfin.unwatchedCount, 4);
});
});
group('MediaItem.hasActiveProgress', () {
test('viewOffset between 0 and duration counts as active progress', () {
expect(_movie(durationMs: 10000, viewOffsetMs: 5000).hasActiveProgress, isTrue);
});
test('viewOffset 0 is NOT active progress (haven\'t started yet)', () {
expect(_movie(durationMs: 10000, viewOffsetMs: 0).hasActiveProgress, isFalse);
});
test('viewOffset >= duration is NOT active progress (already finished)', () {
expect(_movie(durationMs: 10000, viewOffsetMs: 10000).hasActiveProgress, isFalse);
expect(_movie(durationMs: 10000, viewOffsetMs: 99999).hasActiveProgress, isFalse);
});
test('null durationMs or viewOffsetMs disables the check', () {
expect(_movie(durationMs: null, viewOffsetMs: 5000).hasActiveProgress, isFalse);
expect(_movie(durationMs: 10000, viewOffsetMs: null).hasActiveProgress, isFalse);
});
});
group('MediaItem.copyWith', () {
test('round-trips an unchanged copy', () {
final original = _movie(viewCount: 1, durationMs: 1000);
final copy = original.copyWith();
expect(copy.id, original.id);
expect(copy.viewCount, original.viewCount);
expect(copy.durationMs, original.durationMs);
expect(copy.kind, original.kind);
});
test('overrides only the named fields', () {
final original = _movie(title: 'Old', viewCount: 0);
final copy = original.copyWith(title: 'New', viewCount: 3);
expect(copy.title, 'New');
expect(copy.viewCount, 3);
expect(copy.id, 'm1', reason: 'untouched fields preserved');
});
test('preserves backend across copyWith for both backends', () {
for (final backend in MediaBackend.values) {
final original = _movie(backend: backend);
expect(original.backend, backend);
expect(original.copyWith(title: 'New').backend, backend, reason: 'copyWith must preserve backend');
}
});
test('preserves Plex-only fields when omitted', () {
const original = PlexMediaItem(
id: 'p1',
kind: MediaKind.movie,
title: 'Old',
editionTitle: 'Director Cut',
ratings: [
MediaRatingSource(source: 'rottenTomatoesCritic', value: 9.4),
MediaRatingSource(source: 'imdb', value: 8.9, votes: 1200),
],
subtitleLanguage: 'eng',
subtitleMode: 1,
trailerKey: '/library/metadata/1',
playlistItemId: 42,
playQueueItemId: 7,
subtype: 'trailer',
extraType: 1,
);
final copy = original.copyWith(title: 'New');
expect(copy.title, 'New');
expect(copy.editionTitle, 'Director Cut');
expect(copy.ratings?.map((rating) => rating.source), ['rottenTomatoesCritic', 'imdb']);
expect(copy.ratings?.last.votes, 1200);
expect(copy.subtitleLanguage, 'eng');
expect(copy.subtitleMode, 1);
expect(copy.trailerKey, '/library/metadata/1');
expect(copy.playlistItemId, 42);
expect(copy.playQueueItemId, 7);
expect(copy.subtype, 'trailer');
expect(copy.extraType, 1);
});
test('preserves Jellyfin playlist item id when omitted', () {
const original = JellyfinMediaItem(
id: 'j1',
kind: MediaKind.movie,
title: 'Old',
playlistItemId: 'playlist-entry-1',
);
final copy = original.copyWith(title: 'New');
expect(copy.title, 'New');
expect(copy.playlistItemId, 'playlist-entry-1');
});
test('can clear nullable fields explicitly', () {
final original = _movie(title: 'Movie', viewCount: 1, durationMs: 1000, viewOffsetMs: 500);
final copy = original.copyWith(title: null, viewOffsetMs: null);
expect(copy.title, isNull);
expect(copy.viewOffsetMs, isNull);
expect(copy.viewCount, 1);
});
});
group('MediaItem JSON', () {
test('round-trips Plex-only fields', () {
const original = PlexMediaItem(
id: 'p1',
kind: MediaKind.movie,
title: 'Movie',
editionTitle: 'Theatrical',
ratings: [
MediaRatingSource(source: 'rottenTomatoesCritic', value: 9.1),
MediaRatingSource(source: 'imdb', value: 8.4, votes: 250858),
],
genres: ['Drama'],
roles: [MediaRole(id: '1', tag: 'Actor', role: 'Lead', thumbPath: '/photo')],
mediaVersions: [
MediaVersion(
id: 'v1',
width: 1920,
height: 1080,
parts: [MediaPart(id: 'part1', streamPath: '/stream', sizeBytes: 1000)],
),
],
subtitleLanguage: 'eng',
subtitleMode: 2,
trailerKey: '/trailer',
playlistItemId: 4,
playQueueItemId: 5,
subtype: 'trailer',
extraType: 9,
);
final json = original.toJson();
final decoded = MediaItem.fromJson(json);
expect(json['backend'], 'plex');
expect(json.containsKey('summary'), isFalse);
expect(decoded, isA<PlexMediaItem>());
final plex = decoded as PlexMediaItem;
expect(plex.editionTitle, 'Theatrical');
expect(plex.ratings?.map((rating) => rating.source), ['rottenTomatoesCritic', 'imdb']);
expect(plex.ratings?.first.value, 9.1);
expect(plex.ratings?.last.votes, 250858);
expect(plex.genres, ['Drama']);
expect(plex.roles?.single.tag, 'Actor');
expect(plex.mediaVersions?.single.parts.single.streamPath, '/stream');
expect(plex.subtitleLanguage, 'eng');
expect(plex.subtitleMode, 2);
expect(plex.trailerKey, '/trailer');
expect(plex.playlistItemId, 4);
expect(plex.playQueueItemId, 5);
expect(plex.subtype, 'trailer');
expect(plex.extraType, 9);
});
test('round-trips Jellyfin playlist item id', () {
const original = JellyfinMediaItem(id: 'j1', kind: MediaKind.movie, title: 'Movie', playlistItemId: 'entry-1');
final json = original.toJson();
final decoded = MediaItem.fromJson(json);
expect(json['backend'], 'jellyfin');
expect(decoded, isA<JellyfinMediaItem>());
expect((decoded as JellyfinMediaItem).playlistItemId, 'entry-1');
});
test('round-trips Jellyfin backdrop lists', () {
const original = JellyfinMediaItem(
id: 'j-backdrops',
kind: MediaKind.episode,
artPath: '/episode-0',
backdropPaths: ['/episode-0', '/episode-1'],
grandparentArtPath: '/show-0',
grandparentBackdropPaths: ['/show-0', '/show-1'],
);
final decoded = MediaItem.fromJson(original.toJson());
expect(decoded.backdropPaths, ['/episode-0', '/episode-1']);
expect(decoded.grandparentBackdropPaths, ['/show-0', '/show-1']);
expect(decoded.heroBackdropPaths, ['/show-0', '/show-1']);
});
test('cached leaf items ignore stale aggregate watch fields', () {
const original = JellyfinMediaItem(
id: 'cached-music-video',
kind: MediaKind.clip,
viewCount: 0,
leafCount: 1,
viewedLeafCount: 1,
raw: {
'UserData': {'UnplayedItemCount': 0},
},
);
final decoded = MediaItem.fromJson(original.toJson());
expect(decoded.viewedLeafCount, 1);
expect(decoded.isWatched, isFalse);
expect(decoded.isPartiallyWatched, isFalse);
expect(decoded.unwatchedCount, isNull);
});
test('missing backend keeps legacy Plex fallback', () {
final decoded = MediaItem.fromJson({'id': 'legacy', 'kind': 'movie'});
expect(decoded, isA<PlexMediaItem>());
expect(decoded.backend, MediaBackend.plex);
expect(decoded.id, 'legacy');
expect(decoded.kind, MediaKind.movie);
});
test('an Emby item persists its own backend id and restores the dialect', () {
const original = JellyfinMediaItem(
dialect: MediaBrowserDialect.emby,
// Emby item ids are short numeric strings, not GUIDs.
id: '7330',
kind: MediaKind.movie,
title: 'Movie 001',
playlistItemId: 'entry-1',
);
final json = original.toJson();
final decoded = MediaItem.fromJson(json);
// One discriminator on the wire: the union key carries the resolved
// backend and the dialect is rebuilt from it.
expect(json['backend'], 'emby');
expect(json.containsKey('dialect'), isFalse);
expect(decoded, isA<JellyfinMediaItem>());
expect(decoded.backend, MediaBackend.emby);
expect((decoded as JellyfinMediaItem).dialect, MediaBrowserDialect.emby);
expect(decoded.playlistItemId, 'entry-1');
expect(decoded.id, '7330');
});
test('the compat factory routes both MediaBrowser backends to one variant', () {
final emby = MediaItem(id: 'e1', backend: MediaBackend.emby, kind: MediaKind.movie);
final jellyfin = MediaItem(id: 'j1', backend: MediaBackend.jellyfin, kind: MediaKind.movie);
expect(emby, isA<JellyfinMediaItem>());
expect(jellyfin, isA<JellyfinMediaItem>());
expect(emby.backend, MediaBackend.emby);
expect(jellyfin.backend, MediaBackend.jellyfin);
});
test('copyWith preserves the Emby dialect', () {
final emby = MediaItem(id: 'e1', backend: MediaBackend.emby, kind: MediaKind.movie) as JellyfinMediaItem;
expect(emby.copyWith(title: 'renamed').backend, MediaBackend.emby);
});
});
group('MediaItem.displayTitle', () {
test('episode prefers grandparent (show) title', () {
final ep = testMediaItem(
id: 'e1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Pilot',
grandparentTitle: 'Breaking Bad',
parentTitle: 'Season 1',
serverId: 's1',
);
expect(ep.displayTitle, 'Breaking Bad');
expect(ep.displaySubtitle, 'Pilot');
});
test('season prefers grandparent over parent (when both present)', () {
final season = testMediaItem(
id: 'sn1',
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'Season 1',
grandparentTitle: 'Breaking Bad',
parentTitle: null,
serverId: 's1',
);
expect(season.displayTitle, 'Breaking Bad');
});
test('movie returns its own title with no subtitle', () {
final movie = _movie(title: 'Inception');
expect(movie.displayTitle, 'Inception');
expect(movie.displaySubtitle, isNull);
});
test('null title degrades to empty string (no NPE)', () {
final movie = _movie(title: null);
expect(movie.displayTitle, '');
});
});
group('MediaItem music metadata', () {
test('album year is exposed only for tracks and albums', () {
final track = testMediaItem(kind: MediaKind.track, parentTitle: 'Album', year: 2001);
final album = testMediaItem(kind: MediaKind.album, title: 'Album', year: 2001);
expect(track.albumTitle, 'Album');
expect(track.albumYear, 2001);
expect(album.albumYear, 2001);
expect(_movie(year: 2001).albumYear, isNull);
});
});
}