refactor(media): split media and jellyfin client

This commit is contained in:
edde746
2026-05-11 05:24:07 +02:00
parent 4cdcd2f2e4
commit 00917a0393
14 changed files with 3582 additions and 3515 deletions
+4 -1487
View File
File diff suppressed because it is too large Load Diff
+682
View File
@@ -0,0 +1,682 @@
part of '../media_item.dart';
/// Backend-neutral media item — the central domain type the app's UI,
/// providers, and persistence layer operate on. Each backend's adapter is
/// responsible for mapping its native representation (Plex `Metadata`,
/// Jellyfin `BaseItemDto`) into this shape.
///
/// Sealed root with two concrete subclasses: [PlexMediaItem] (carries
/// Plex-only fields like `trailerKey`, `playQueueItemId`, `audienceRating`)
/// and [JellyfinMediaItem] (only the backend-neutral fields). Read sites
/// that need a Plex-only field type-narrow with
/// `case PlexMediaItem(:final trailerKey?)` or
/// `if (item is PlexMediaItem) item.trailerKey`.
sealed class MediaItem {
/// Backend-opaque identifier (Plex `ratingKey`, Jellyfin `Id`).
final String id;
final MediaBackend backend;
final MediaKind kind;
/// Stable cross-backend identifier (Plex `guid`, Jellyfin `Id` URI). Used
/// for matching across servers and for Trakt-style external lookups.
final String? guid;
final String? title;
final String? titleSort;
final String? summary;
final String? tagline;
final String? originalTitle;
final String? studio;
final int? year;
/// Original release date (`YYYY-MM-DD`).
final String? originallyAvailableAt;
final String? contentRating;
final String? parentId;
final String? parentTitle;
final String? parentThumbPath;
final int? parentIndex;
final int? index;
final String? grandparentId;
final String? grandparentTitle;
final String? grandparentThumbPath;
final String? grandparentArtPath;
final String? thumbPath;
final String? artPath;
final String? clearLogoPath;
final String? backgroundSquarePath;
final int? durationMs;
/// Resume position in ms.
final int? viewOffsetMs;
final int? viewCount;
final int? lastViewedAt;
/// Total leaf items (episodes in a show/season, items in a collection).
final int? leafCount;
/// Watched leaf items.
final int? viewedLeafCount;
/// Direct children count (e.g. seasons in a show).
final int? childCount;
final int? addedAt;
final int? updatedAt;
final double? rating;
final double? userRating;
final List<String>? genres;
final List<String>? directors;
final List<String>? writers;
final List<String>? producers;
final List<String>? countries;
final List<String>? collections;
final List<String>? labels;
final List<String>? styles;
final List<String>? moods;
final List<MediaRole>? roles;
final List<MediaVersion>? mediaVersions;
/// Backend-opaque library/section id this item belongs to.
final String? libraryId;
final String? libraryTitle;
/// Preferred audio language for this item — used by track-selection
/// fallback (Priority 3) on both backends. Plex persists changes via
/// [PlexClient.setMetadataPreferences]; Jellyfin populates it from the
/// per-user `PreferredMetadataLanguage` field but has no per-item write
/// endpoint, so the value is read-only there.
final String? audioLanguage;
final String? serverId;
final String? serverName;
/// Untyped fall-through for backend-specific fields not yet mapped onto a
/// typed accessor. Use sparingly; promote to typed fields when stable.
final Map<String, Object?>? raw;
const MediaItem._({
required this.id,
required this.backend,
required this.kind,
this.guid,
this.title,
this.titleSort,
this.summary,
this.tagline,
this.originalTitle,
this.studio,
this.year,
this.originallyAvailableAt,
this.contentRating,
this.parentId,
this.parentTitle,
this.parentThumbPath,
this.parentIndex,
this.index,
this.grandparentId,
this.grandparentTitle,
this.grandparentThumbPath,
this.grandparentArtPath,
this.thumbPath,
this.artPath,
this.clearLogoPath,
this.backgroundSquarePath,
this.durationMs,
this.viewOffsetMs,
this.viewCount,
this.lastViewedAt,
this.leafCount,
this.viewedLeafCount,
this.childCount,
this.addedAt,
this.updatedAt,
this.rating,
this.userRating,
this.genres,
this.directors,
this.writers,
this.producers,
this.countries,
this.collections,
this.labels,
this.styles,
this.moods,
this.roles,
this.mediaVersions,
this.libraryId,
this.libraryTitle,
this.audioLanguage,
this.serverId,
this.serverName,
this.raw,
});
/// Backend-dispatching factory: constructs the right concrete subclass
/// for the given [backend].
factory MediaItem({
required String id,
required MediaBackend backend,
required MediaKind kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? userRating,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
/// Plex-only — silently ignored when [backend] is Jellyfin (Jellyfin has
/// no per-item subtitle preference write endpoint). Forwarded to
/// [PlexMediaItem] only.
String? subtitleLanguage,
int? subtitleMode,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
}) {
return switch (backend) {
MediaBackend.plex => PlexMediaItem(
id: id,
kind: kind,
guid: guid,
title: title,
titleSort: titleSort,
summary: summary,
tagline: tagline,
originalTitle: originalTitle,
studio: studio,
year: year,
originallyAvailableAt: originallyAvailableAt,
contentRating: contentRating,
parentId: parentId,
parentTitle: parentTitle,
parentThumbPath: parentThumbPath,
parentIndex: parentIndex,
index: index,
grandparentId: grandparentId,
grandparentTitle: grandparentTitle,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: grandparentArtPath,
thumbPath: thumbPath,
artPath: artPath,
clearLogoPath: clearLogoPath,
backgroundSquarePath: backgroundSquarePath,
durationMs: durationMs,
viewOffsetMs: viewOffsetMs,
viewCount: viewCount,
lastViewedAt: lastViewedAt,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
childCount: childCount,
addedAt: addedAt,
updatedAt: updatedAt,
rating: rating,
userRating: userRating,
genres: genres,
directors: directors,
writers: writers,
producers: producers,
countries: countries,
collections: collections,
labels: labels,
styles: styles,
moods: moods,
roles: roles,
mediaVersions: mediaVersions,
libraryId: libraryId,
libraryTitle: libraryTitle,
audioLanguage: audioLanguage,
subtitleLanguage: subtitleLanguage,
subtitleMode: subtitleMode,
serverId: serverId,
serverName: serverName,
raw: raw,
),
MediaBackend.jellyfin => JellyfinMediaItem(
id: id,
kind: kind,
guid: guid,
title: title,
titleSort: titleSort,
summary: summary,
tagline: tagline,
originalTitle: originalTitle,
studio: studio,
year: year,
originallyAvailableAt: originallyAvailableAt,
contentRating: contentRating,
parentId: parentId,
parentTitle: parentTitle,
parentThumbPath: parentThumbPath,
parentIndex: parentIndex,
index: index,
grandparentId: grandparentId,
grandparentTitle: grandparentTitle,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: grandparentArtPath,
thumbPath: thumbPath,
artPath: artPath,
clearLogoPath: clearLogoPath,
backgroundSquarePath: backgroundSquarePath,
durationMs: durationMs,
viewOffsetMs: viewOffsetMs,
viewCount: viewCount,
lastViewedAt: lastViewedAt,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
childCount: childCount,
addedAt: addedAt,
updatedAt: updatedAt,
rating: rating,
userRating: userRating,
genres: genres,
directors: directors,
writers: writers,
producers: producers,
countries: countries,
collections: collections,
labels: labels,
styles: styles,
moods: moods,
roles: roles,
mediaVersions: mediaVersions,
libraryId: libraryId,
libraryTitle: libraryTitle,
audioLanguage: audioLanguage,
serverId: serverId,
serverName: serverName,
raw: raw,
),
};
}
/// Global unique identifier across all servers (`serverId:id`). Falls back
/// to bare [id] if [serverId] is missing.
String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id;
/// Global unique identifier of this item's library section.
String? get libraryGlobalKey => serverId != null && libraryId != null ? buildGlobalKey(serverId!, libraryId!) : null;
/// Parent rating keys for hierarchical invalidation. For an episode:
/// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`.
List<String> get parentChain => [?parentId, ?grandparentId];
/// Whether this item has started but not finished playback.
bool get hasActiveProgress {
if (durationMs == null || viewOffsetMs == null) return false;
return viewOffsetMs! > 0 && viewOffsetMs! < durationMs!;
}
/// Whether this container (show/season) has some but not all leaves watched.
bool get isPartiallyWatched =>
viewedLeafCount != null && leafCount != null && viewedLeafCount! > 0 && viewedLeafCount! < leafCount!;
/// Whether the item is fully watched. Series/seasons consult leaf counts;
/// individual movies/episodes use [viewCount].
bool get isWatched {
if (leafCount != null && viewedLeafCount != null) {
return viewedLeafCount! >= leafCount!;
}
return viewCount != null && viewCount! > 0;
}
/// Display-friendly title that prefers the show name for episodes/seasons.
String get displayTitle {
if ((kind == MediaKind.episode || kind == MediaKind.season) && grandparentTitle != null) {
return grandparentTitle!;
}
if (kind == MediaKind.season && parentTitle != null) {
return parentTitle!;
}
return title ?? '';
}
/// Subtitle line shown below [displayTitle] for episodes/seasons.
String? get displaySubtitle {
if (kind == MediaKind.episode || kind == MediaKind.season) {
if (grandparentTitle != null || (kind == MediaKind.season && parentTitle != null)) {
return title;
}
}
return null;
}
/// Plex-only edition label (e.g. "Director's Cut"). Returns null on
/// backends that don't model editions; lets callers avoid type-narrowing
/// to [PlexMediaItem] just to read this field.
String? get editionTitle => null;
/// Returns the appropriate poster path based on episode poster mode.
///
/// For episodes:
/// - `seriesPoster`: grandparentThumb (series poster)
/// - `seasonPoster`: parentThumb (season poster)
/// - `episodeThumbnail`: thumb (16:9 episode still)
///
/// For seasons: returns grandparentThumb (series poster), or art/thumb in
/// mixed hub context.
/// For movies/shows in mixed hub context with episode-thumbnail mode:
/// returns art (16:9 background).
/// For other types: returns thumb.
String? posterThumb({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) {
if (kind == MediaKind.episode) {
switch (mode) {
case EpisodePosterMode.episodeThumbnail:
return thumbPath;
case EpisodePosterMode.seasonPoster:
return parentThumbPath ?? grandparentThumbPath ?? thumbPath;
case EpisodePosterMode.seriesPoster:
return grandparentThumbPath ?? thumbPath;
}
} else if (kind == MediaKind.season) {
if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail) {
return artPath ?? thumbPath;
}
if (grandparentThumbPath != null) {
return grandparentThumbPath;
}
}
if (mixedHubContext &&
mode == EpisodePosterMode.episodeThumbnail &&
(kind == MediaKind.movie || kind == MediaKind.show)) {
return artPath ?? thumbPath;
}
return thumbPath;
}
/// Secondary poster path to try when [posterThumb] returns an image URL that
/// exists syntactically but the server cannot serve it.
String? posterThumbFallback({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) {
if (kind != MediaKind.episode || mode != EpisodePosterMode.seasonPoster) return null;
final fallback = grandparentThumbPath ?? thumbPath;
return fallback != null && fallback != posterThumb(mode: mode, mixedHubContext: mixedHubContext) ? fallback : null;
}
/// True when the item should render in 16:9.
/// - Clips are always 16:9.
/// - Episodes are 16:9 in `episodeThumbnail` mode.
/// - Movies/shows/seasons are 16:9 in mixed-hub `episodeThumbnail` context.
bool usesWideAspectRatio(EpisodePosterMode mode, {bool mixedHubContext = false}) {
if (kind == MediaKind.clip) return true;
if (kind == MediaKind.episode && mode == EpisodePosterMode.episodeThumbnail) {
return true;
}
if (mixedHubContext &&
mode == EpisodePosterMode.episodeThumbnail &&
(kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.season)) {
return true;
}
return false;
}
/// Returns the best hero art path based on the container's aspect ratio.
/// Uses backgroundSquare when the container is closer to 1:1 than 16:9.
String? heroArt({required double containerAspectRatio}) {
final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio);
if (candidates.isEmpty) return null;
return candidates.first;
}
/// Returns hero art candidates in display-preference order.
/// Near-square containers prefer square art, then fall back to wide cover art.
List<String> heroArtCandidates({required double containerAspectRatio}) {
// Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39
final preferred = containerAspectRatio < 1.39 ? [backgroundSquarePath, artPath] : [artPath, backgroundSquarePath];
final candidates = <String>[];
for (final path in preferred) {
if (path == null || path.isEmpty || candidates.contains(path)) continue;
candidates.add(path);
}
return candidates;
}
MediaItem copyWith({
String? id,
MediaBackend? backend,
MediaKind? kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? userRating,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
/// Plex-only — forwarded only when this item is a [PlexMediaItem].
String? subtitleLanguage,
int? subtitleMode,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
}) {
return MediaItem(
id: id ?? this.id,
backend: backend ?? this.backend,
kind: kind ?? this.kind,
guid: guid ?? this.guid,
title: title ?? this.title,
titleSort: titleSort ?? this.titleSort,
summary: summary ?? this.summary,
tagline: tagline ?? this.tagline,
originalTitle: originalTitle ?? this.originalTitle,
studio: studio ?? this.studio,
year: year ?? this.year,
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
contentRating: contentRating ?? this.contentRating,
parentId: parentId ?? this.parentId,
parentTitle: parentTitle ?? this.parentTitle,
parentThumbPath: parentThumbPath ?? this.parentThumbPath,
parentIndex: parentIndex ?? this.parentIndex,
index: index ?? this.index,
grandparentId: grandparentId ?? this.grandparentId,
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath,
grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath,
thumbPath: thumbPath ?? this.thumbPath,
artPath: artPath ?? this.artPath,
clearLogoPath: clearLogoPath ?? this.clearLogoPath,
backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath,
durationMs: durationMs ?? this.durationMs,
viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs,
viewCount: viewCount ?? this.viewCount,
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
leafCount: leafCount ?? this.leafCount,
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
childCount: childCount ?? this.childCount,
addedAt: addedAt ?? this.addedAt,
updatedAt: updatedAt ?? this.updatedAt,
rating: rating ?? this.rating,
userRating: userRating ?? this.userRating,
genres: genres ?? this.genres,
directors: directors ?? this.directors,
writers: writers ?? this.writers,
producers: producers ?? this.producers,
countries: countries ?? this.countries,
collections: collections ?? this.collections,
labels: labels ?? this.labels,
styles: styles ?? this.styles,
moods: moods ?? this.moods,
roles: roles ?? this.roles,
mediaVersions: mediaVersions ?? this.mediaVersions,
libraryId: libraryId ?? this.libraryId,
libraryTitle: libraryTitle ?? this.libraryTitle,
audioLanguage: audioLanguage ?? this.audioLanguage,
// [subtitleLanguage] / [subtitleMode] are Plex-only fields. Base
// [MediaItem] doesn't carry them; [PlexMediaItem.copyWith] overrides
// this method and forwards its own copies. For Jellyfin items the
// params are silently dropped.
subtitleLanguage: subtitleLanguage,
subtitleMode: subtitleMode,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
raw: raw ?? this.raw,
);
}
/// Serialize to a backend-neutral JSON map. Used by the offline cache so
/// downloads retain their metadata without round-tripping through a
/// backend-specific shape.
///
/// Subclasses extend this with their own backend-specific keys
/// ([PlexMediaItem.toJson] adds the Plex-only fields).
Map<String, dynamic> toJson() {
return {
'id': id,
'backend': backend.id,
'kind': kind.id,
if (guid != null) 'guid': guid,
if (title != null) 'title': title,
if (titleSort != null) 'titleSort': titleSort,
if (summary != null) 'summary': summary,
if (tagline != null) 'tagline': tagline,
if (originalTitle != null) 'originalTitle': originalTitle,
if (studio != null) 'studio': studio,
if (year != null) 'year': year,
if (originallyAvailableAt != null) 'originallyAvailableAt': originallyAvailableAt,
if (contentRating != null) 'contentRating': contentRating,
if (parentId != null) 'parentId': parentId,
if (parentTitle != null) 'parentTitle': parentTitle,
if (parentThumbPath != null) 'parentThumbPath': parentThumbPath,
if (parentIndex != null) 'parentIndex': parentIndex,
if (index != null) 'index': index,
if (grandparentId != null) 'grandparentId': grandparentId,
if (grandparentTitle != null) 'grandparentTitle': grandparentTitle,
if (grandparentThumbPath != null) 'grandparentThumbPath': grandparentThumbPath,
if (grandparentArtPath != null) 'grandparentArtPath': grandparentArtPath,
if (thumbPath != null) 'thumbPath': thumbPath,
if (artPath != null) 'artPath': artPath,
if (clearLogoPath != null) 'clearLogoPath': clearLogoPath,
if (backgroundSquarePath != null) 'backgroundSquarePath': backgroundSquarePath,
if (durationMs != null) 'durationMs': durationMs,
if (viewOffsetMs != null) 'viewOffsetMs': viewOffsetMs,
if (viewCount != null) 'viewCount': viewCount,
if (lastViewedAt != null) 'lastViewedAt': lastViewedAt,
if (leafCount != null) 'leafCount': leafCount,
if (viewedLeafCount != null) 'viewedLeafCount': viewedLeafCount,
if (childCount != null) 'childCount': childCount,
if (addedAt != null) 'addedAt': addedAt,
if (updatedAt != null) 'updatedAt': updatedAt,
if (rating != null) 'rating': rating,
if (userRating != null) 'userRating': userRating,
if (genres != null) 'genres': genres,
if (directors != null) 'directors': directors,
if (writers != null) 'writers': writers,
if (producers != null) 'producers': producers,
if (countries != null) 'countries': countries,
if (collections != null) 'collections': collections,
if (labels != null) 'labels': labels,
if (styles != null) 'styles': styles,
if (moods != null) 'moods': moods,
if (roles != null) 'roles': [for (final r in roles!) _roleToJson(r)],
if (mediaVersions != null) 'mediaVersions': [for (final v in mediaVersions!) _versionToJson(v)],
if (libraryId != null) 'libraryId': libraryId,
if (libraryTitle != null) 'libraryTitle': libraryTitle,
if (audioLanguage != null) 'audioLanguage': audioLanguage,
if (serverId != null) 'serverId': serverId,
if (serverName != null) 'serverName': serverName,
if (raw != null) 'raw': raw,
};
}
/// Restore a [MediaItem] from a [toJson] payload. Dispatches to
/// [PlexMediaItem.fromJson] when the payload's `backend` tag is Plex so
/// the Plex-only fields round-trip correctly. Unknown shapes degrade to a
/// minimal item carrying just `id` so cache misses don't crash.
factory MediaItem.fromJson(Map<String, dynamic> json) {
final backend = MediaBackend.fromString(json['backend'] as String?);
if (backend == MediaBackend.plex) return PlexMediaItem.fromJson(json);
return JellyfinMediaItem.fromJson(json);
}
}
+258
View File
@@ -0,0 +1,258 @@
part of '../media_item.dart';
/// Backend-tagged concrete subclass for items sourced from a Jellyfin
/// server. Carries only the backend-neutral fields — Plex-only fields
/// (trailerKey, audienceRating, etc.) live on [PlexMediaItem] instead.
final class JellyfinMediaItem extends MediaItem {
/// Jellyfin per-playlist item id — only set when the item came out of
/// `/Playlists/{id}/Items`. Used as the `entryIds` / move-target id for
/// the playlist write endpoints. Null outside playlist contexts.
final String? playlistItemId;
const JellyfinMediaItem({
required super.id,
required super.kind,
super.guid,
super.title,
super.titleSort,
super.summary,
super.tagline,
super.originalTitle,
super.studio,
super.year,
super.originallyAvailableAt,
super.contentRating,
super.parentId,
super.parentTitle,
super.parentThumbPath,
super.parentIndex,
super.index,
super.grandparentId,
super.grandparentTitle,
super.grandparentThumbPath,
super.grandparentArtPath,
super.thumbPath,
super.artPath,
super.clearLogoPath,
super.backgroundSquarePath,
super.durationMs,
super.viewOffsetMs,
super.viewCount,
super.lastViewedAt,
super.leafCount,
super.viewedLeafCount,
super.childCount,
super.addedAt,
super.updatedAt,
super.rating,
super.userRating,
super.genres,
super.directors,
super.writers,
super.producers,
super.countries,
super.collections,
super.labels,
super.styles,
super.moods,
super.roles,
super.mediaVersions,
super.libraryId,
super.libraryTitle,
super.audioLanguage,
this.playlistItemId,
super.serverId,
super.serverName,
super.raw,
}) : super._(backend: MediaBackend.jellyfin);
/// Override the base [MediaItem.copyWith] so [playlistItemId] survives
/// round-trips through the absolutizer (which calls copyWith to rewrite
/// image paths). Without this, every Jellyfin playlist item came out with
/// `playlistItemId == null` after mapping, making the move/remove endpoints
/// silently no-op.
@override
JellyfinMediaItem copyWith({
String? id,
MediaBackend? backend,
MediaKind? kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? userRating,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
String? subtitleLanguage,
int? subtitleMode,
String? playlistItemId,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
}) {
return JellyfinMediaItem(
id: id ?? this.id,
kind: kind ?? this.kind,
guid: guid ?? this.guid,
title: title ?? this.title,
titleSort: titleSort ?? this.titleSort,
summary: summary ?? this.summary,
tagline: tagline ?? this.tagline,
originalTitle: originalTitle ?? this.originalTitle,
studio: studio ?? this.studio,
year: year ?? this.year,
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
contentRating: contentRating ?? this.contentRating,
parentId: parentId ?? this.parentId,
parentTitle: parentTitle ?? this.parentTitle,
parentThumbPath: parentThumbPath ?? this.parentThumbPath,
parentIndex: parentIndex ?? this.parentIndex,
index: index ?? this.index,
grandparentId: grandparentId ?? this.grandparentId,
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath,
grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath,
thumbPath: thumbPath ?? this.thumbPath,
artPath: artPath ?? this.artPath,
clearLogoPath: clearLogoPath ?? this.clearLogoPath,
backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath,
durationMs: durationMs ?? this.durationMs,
viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs,
viewCount: viewCount ?? this.viewCount,
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
leafCount: leafCount ?? this.leafCount,
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
childCount: childCount ?? this.childCount,
addedAt: addedAt ?? this.addedAt,
updatedAt: updatedAt ?? this.updatedAt,
rating: rating ?? this.rating,
userRating: userRating ?? this.userRating,
genres: genres ?? this.genres,
directors: directors ?? this.directors,
writers: writers ?? this.writers,
producers: producers ?? this.producers,
countries: countries ?? this.countries,
collections: collections ?? this.collections,
labels: labels ?? this.labels,
styles: styles ?? this.styles,
moods: moods ?? this.moods,
roles: roles ?? this.roles,
mediaVersions: mediaVersions ?? this.mediaVersions,
libraryId: libraryId ?? this.libraryId,
libraryTitle: libraryTitle ?? this.libraryTitle,
audioLanguage: audioLanguage ?? this.audioLanguage,
playlistItemId: playlistItemId ?? this.playlistItemId,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
raw: raw ?? this.raw,
);
}
@override
Map<String, dynamic> toJson() {
return {...super.toJson(), if (playlistItemId != null) 'playlistItemId': playlistItemId};
}
/// Restore a [JellyfinMediaItem] from a [toJson] payload. Used as the
/// non-Plex fallback by [MediaItem.fromJson].
factory JellyfinMediaItem.fromJson(Map<String, dynamic> json) {
final base = _parseBaseFields(json);
return JellyfinMediaItem(
id: base.id,
kind: base.kind,
guid: base.guid,
title: base.title,
titleSort: base.titleSort,
summary: base.summary,
tagline: base.tagline,
originalTitle: base.originalTitle,
studio: base.studio,
year: base.year,
originallyAvailableAt: base.originallyAvailableAt,
contentRating: base.contentRating,
parentId: base.parentId,
parentTitle: base.parentTitle,
parentThumbPath: base.parentThumbPath,
parentIndex: base.parentIndex,
index: base.index,
grandparentId: base.grandparentId,
grandparentTitle: base.grandparentTitle,
grandparentThumbPath: base.grandparentThumbPath,
grandparentArtPath: base.grandparentArtPath,
thumbPath: base.thumbPath,
artPath: base.artPath,
clearLogoPath: base.clearLogoPath,
backgroundSquarePath: base.backgroundSquarePath,
durationMs: base.durationMs,
viewOffsetMs: base.viewOffsetMs,
viewCount: base.viewCount,
lastViewedAt: base.lastViewedAt,
leafCount: base.leafCount,
viewedLeafCount: base.viewedLeafCount,
childCount: base.childCount,
addedAt: base.addedAt,
updatedAt: base.updatedAt,
rating: base.rating,
userRating: base.userRating,
genres: base.genres,
directors: base.directors,
writers: base.writers,
producers: base.producers,
countries: base.countries,
collections: base.collections,
labels: base.labels,
styles: base.styles,
moods: base.moods,
roles: base.roles,
mediaVersions: base.mediaVersions,
libraryId: base.libraryId,
libraryTitle: base.libraryTitle,
audioLanguage: base.audioLanguage,
playlistItemId: json['playlistItemId'] as String?,
serverId: base.serverId,
serverName: base.serverName,
raw: base.raw,
);
}
}
+200
View File
@@ -0,0 +1,200 @@
part of '../media_item.dart';
/// Shared parsing of the backend-neutral fields. Returns a typed record
/// consumed by both [JellyfinMediaItem.fromJson] and
/// [PlexMediaItem.fromJson] (which layers the Plex-only fields on top).
typedef _BaseFields = ({
String id,
MediaKind kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? userRating,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
});
_BaseFields _parseBaseFields(Map<String, dynamic> json) {
final rolesRaw = json['roles'];
final versionsRaw = json['mediaVersions'];
return (
id: (json['id'] ?? '').toString(),
kind: MediaKind.fromString(json['kind'] as String?),
guid: json['guid'] as String?,
title: json['title'] as String?,
titleSort: json['titleSort'] as String?,
summary: json['summary'] as String?,
tagline: json['tagline'] as String?,
originalTitle: json['originalTitle'] as String?,
studio: json['studio'] as String?,
year: flexibleInt(json['year']),
originallyAvailableAt: json['originallyAvailableAt'] as String?,
contentRating: json['contentRating'] as String?,
parentId: json['parentId'] as String?,
parentTitle: json['parentTitle'] as String?,
parentThumbPath: json['parentThumbPath'] as String?,
parentIndex: flexibleInt(json['parentIndex']),
index: flexibleInt(json['index']),
grandparentId: json['grandparentId'] as String?,
grandparentTitle: json['grandparentTitle'] as String?,
grandparentThumbPath: json['grandparentThumbPath'] as String?,
grandparentArtPath: json['grandparentArtPath'] as String?,
thumbPath: json['thumbPath'] as String?,
artPath: json['artPath'] as String?,
clearLogoPath: json['clearLogoPath'] as String?,
backgroundSquarePath: json['backgroundSquarePath'] as String?,
durationMs: flexibleInt(json['durationMs']),
viewOffsetMs: flexibleInt(json['viewOffsetMs']),
viewCount: flexibleInt(json['viewCount']),
lastViewedAt: flexibleInt(json['lastViewedAt']),
leafCount: flexibleInt(json['leafCount']),
viewedLeafCount: flexibleInt(json['viewedLeafCount']),
childCount: flexibleInt(json['childCount']),
addedAt: flexibleInt(json['addedAt']),
updatedAt: flexibleInt(json['updatedAt']),
rating: flexibleDouble(json['rating']),
userRating: flexibleDouble(json['userRating']),
genres: _stringList(json['genres']),
directors: _stringList(json['directors']),
writers: _stringList(json['writers']),
producers: _stringList(json['producers']),
countries: _stringList(json['countries']),
collections: _stringList(json['collections']),
labels: _stringList(json['labels']),
styles: _stringList(json['styles']),
moods: _stringList(json['moods']),
roles: rolesRaw is List
? [
for (final r in rolesRaw)
if (r is Map<String, dynamic>) _roleFromJson(r),
]
: null,
mediaVersions: versionsRaw is List
? [
for (final v in versionsRaw)
if (v is Map<String, dynamic>) _versionFromJson(v),
]
: null,
libraryId: json['libraryId'] as String?,
libraryTitle: json['libraryTitle'] as String?,
audioLanguage: json['audioLanguage'] as String?,
serverId: json['serverId'] as String?,
serverName: json['serverName'] as String?,
raw: json['raw'] is Map ? Map<String, Object?>.from(json['raw'] as Map) : null,
);
}
List<String>? _stringList(Object? raw) {
return stringListFromRaw(raw, stringify: true);
}
Map<String, dynamic> _roleToJson(MediaRole role) => {
if (role.id != null) 'id': role.id,
'tag': role.tag,
if (role.role != null) 'role': role.role,
if (role.thumbPath != null) 'thumbPath': role.thumbPath,
};
MediaRole _roleFromJson(Map<String, dynamic> json) => MediaRole(
id: json['id'] as String?,
tag: (json['tag'] ?? '').toString(),
role: json['role'] as String?,
thumbPath: json['thumbPath'] as String?,
);
Map<String, dynamic> _versionToJson(MediaVersion v) => {
'id': v.id,
if (v.width != null) 'width': v.width,
if (v.height != null) 'height': v.height,
if (v.videoResolution != null) 'videoResolution': v.videoResolution,
if (v.videoCodec != null) 'videoCodec': v.videoCodec,
if (v.bitrate != null) 'bitrate': v.bitrate,
if (v.container != null) 'container': v.container,
if (v.name != null) 'name': v.name,
'parts': [
for (final p in v.parts)
{
'id': p.id,
if (p.streamPath != null) 'streamPath': p.streamPath,
if (p.sizeBytes != null) 'sizeBytes': p.sizeBytes,
if (p.container != null) 'container': p.container,
if (p.durationMs != null) 'durationMs': p.durationMs,
if (p.accessible != null) 'accessible': p.accessible,
if (p.exists != null) 'exists': p.exists,
},
],
};
MediaVersion _versionFromJson(Map<String, dynamic> json) {
final partsRaw = json['parts'];
return MediaVersion(
id: (json['id'] ?? '').toString(),
width: flexibleInt(json['width']),
height: flexibleInt(json['height']),
videoResolution: json['videoResolution'] as String?,
videoCodec: json['videoCodec'] as String?,
bitrate: flexibleInt(json['bitrate']),
container: json['container'] as String?,
name: json['name'] as String?,
parts: partsRaw is List
? [
for (final p in partsRaw)
if (p is Map<String, dynamic>)
MediaPart(
id: (p['id'] ?? '').toString(),
streamPath: p['streamPath'] as String?,
sizeBytes: flexibleInt(p['sizeBytes']),
container: p['container'] as String?,
durationMs: flexibleInt(p['durationMs']),
accessible: p['accessible'] as bool?,
exists: p['exists'] as bool?,
),
]
: const [],
);
}
+351
View File
@@ -0,0 +1,351 @@
part of '../media_item.dart';
/// Backend-tagged concrete subclass for items sourced from a Plex server.
/// Carries the Plex-only fields that have no Jellyfin equivalent
/// (trailerKey, playlistItemId, playQueueItemId, subtype, extraType,
/// ratingImage, audienceRating, audienceRatingImage, editionTitle).
/// Read sites that need these fields type-narrow with
/// `case PlexMediaItem(:final trailerKey?)` or
/// `if (item is PlexMediaItem) item.trailerKey`.
final class PlexMediaItem extends MediaItem {
/// Plex `editionTitle` — secondary title that distinguishes editions of
/// the same movie ("Director's Cut", "Theatrical"). Jellyfin has no
/// equivalent metadata field today.
@override
final String? editionTitle;
/// Plex `audienceRating` (e.g. Rotten Tomatoes audience score). Jellyfin's
/// `CommunityRating` lives on [rating]; there's no separate audience field.
final double? audienceRating;
/// Plex `ratingImage` URI ("rottentomatoes://image.rating.ripe"). Used by
/// the rating chip to pick an icon. Jellyfin doesn't expose
/// rating-source attribution.
final String? ratingImage;
/// Plex `audienceRatingImage` URI — companion to [ratingImage] for the
/// audience score icon.
final String? audienceRatingImage;
/// Plex per-item subtitle language preference — persisted server-side via
/// [PlexClient.setMetadataPreferences]. Jellyfin has no equivalent
/// per-item write endpoint, so the field lives here rather than on the
/// neutral [MediaItem] base.
final String? subtitleLanguage;
/// Plex per-item subtitle mode (`0` = manual, `1` = always on, `2` = match
/// audio). Jellyfin doesn't expose a comparable knob.
final int? subtitleMode;
/// Plex `primaryExtraKey` — points at the main trailer extra. Jellyfin
/// stores trailers separately via `RemoteTrailers`; not yet wired.
final String? trailerKey;
/// Plex playlist item id — only set when the item came out of a
/// server-side playlist. Jellyfin has no per-playlist-item id.
final int? playlistItemId;
/// Plex play-queue item id — set when the item is part of a server-side
/// `PlayQueue`. Jellyfin uses client-side queues; [PlaybackStateProvider]
/// tracks synthetic IDs in a parallel map for those.
final int? playQueueItemId;
/// Plex clip subtype: `trailer`, `behindTheScenes`, `deleted`, etc.
final String? subtype;
/// Plex numeric extra type identifier.
final int? extraType;
const PlexMediaItem({
required super.id,
required super.kind,
super.guid,
super.title,
super.titleSort,
super.summary,
super.tagline,
super.originalTitle,
this.editionTitle,
super.studio,
super.year,
super.originallyAvailableAt,
super.contentRating,
super.parentId,
super.parentTitle,
super.parentThumbPath,
super.parentIndex,
super.index,
super.grandparentId,
super.grandparentTitle,
super.grandparentThumbPath,
super.grandparentArtPath,
super.thumbPath,
super.artPath,
super.clearLogoPath,
super.backgroundSquarePath,
super.durationMs,
super.viewOffsetMs,
super.viewCount,
super.lastViewedAt,
super.leafCount,
super.viewedLeafCount,
super.childCount,
super.addedAt,
super.updatedAt,
super.rating,
this.audienceRating,
super.userRating,
this.ratingImage,
this.audienceRatingImage,
super.genres,
super.directors,
super.writers,
super.producers,
super.countries,
super.collections,
super.labels,
super.styles,
super.moods,
super.roles,
super.mediaVersions,
super.libraryId,
super.libraryTitle,
super.audioLanguage,
this.subtitleLanguage,
this.subtitleMode,
this.trailerKey,
this.playlistItemId,
this.playQueueItemId,
this.subtype,
this.extraType,
super.serverId,
super.serverName,
super.raw,
}) : super._(backend: MediaBackend.plex);
@override
PlexMediaItem copyWith({
String? id,
MediaBackend? backend,
MediaKind? kind,
String? guid,
String? title,
String? titleSort,
String? summary,
String? tagline,
String? originalTitle,
String? editionTitle,
String? studio,
int? year,
String? originallyAvailableAt,
String? contentRating,
String? parentId,
String? parentTitle,
String? parentThumbPath,
int? parentIndex,
int? index,
String? grandparentId,
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
String? thumbPath,
String? artPath,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
int? viewOffsetMs,
int? viewCount,
int? lastViewedAt,
int? leafCount,
int? viewedLeafCount,
int? childCount,
int? addedAt,
int? updatedAt,
double? rating,
double? audienceRating,
double? userRating,
String? ratingImage,
String? audienceRatingImage,
List<String>? genres,
List<String>? directors,
List<String>? writers,
List<String>? producers,
List<String>? countries,
List<String>? collections,
List<String>? labels,
List<String>? styles,
List<String>? moods,
List<MediaRole>? roles,
List<MediaVersion>? mediaVersions,
String? libraryId,
String? libraryTitle,
String? audioLanguage,
String? subtitleLanguage,
int? subtitleMode,
String? trailerKey,
int? playlistItemId,
int? playQueueItemId,
String? subtype,
int? extraType,
String? serverId,
String? serverName,
Map<String, Object?>? raw,
}) {
return PlexMediaItem(
id: id ?? this.id,
kind: kind ?? this.kind,
guid: guid ?? this.guid,
title: title ?? this.title,
titleSort: titleSort ?? this.titleSort,
summary: summary ?? this.summary,
tagline: tagline ?? this.tagline,
originalTitle: originalTitle ?? this.originalTitle,
editionTitle: editionTitle ?? this.editionTitle,
studio: studio ?? this.studio,
year: year ?? this.year,
originallyAvailableAt: originallyAvailableAt ?? this.originallyAvailableAt,
contentRating: contentRating ?? this.contentRating,
parentId: parentId ?? this.parentId,
parentTitle: parentTitle ?? this.parentTitle,
parentThumbPath: parentThumbPath ?? this.parentThumbPath,
parentIndex: parentIndex ?? this.parentIndex,
index: index ?? this.index,
grandparentId: grandparentId ?? this.grandparentId,
grandparentTitle: grandparentTitle ?? this.grandparentTitle,
grandparentThumbPath: grandparentThumbPath ?? this.grandparentThumbPath,
grandparentArtPath: grandparentArtPath ?? this.grandparentArtPath,
thumbPath: thumbPath ?? this.thumbPath,
artPath: artPath ?? this.artPath,
clearLogoPath: clearLogoPath ?? this.clearLogoPath,
backgroundSquarePath: backgroundSquarePath ?? this.backgroundSquarePath,
durationMs: durationMs ?? this.durationMs,
viewOffsetMs: viewOffsetMs ?? this.viewOffsetMs,
viewCount: viewCount ?? this.viewCount,
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
leafCount: leafCount ?? this.leafCount,
viewedLeafCount: viewedLeafCount ?? this.viewedLeafCount,
childCount: childCount ?? this.childCount,
addedAt: addedAt ?? this.addedAt,
updatedAt: updatedAt ?? this.updatedAt,
rating: rating ?? this.rating,
audienceRating: audienceRating ?? this.audienceRating,
userRating: userRating ?? this.userRating,
ratingImage: ratingImage ?? this.ratingImage,
audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage,
genres: genres ?? this.genres,
directors: directors ?? this.directors,
writers: writers ?? this.writers,
producers: producers ?? this.producers,
countries: countries ?? this.countries,
collections: collections ?? this.collections,
labels: labels ?? this.labels,
styles: styles ?? this.styles,
moods: moods ?? this.moods,
roles: roles ?? this.roles,
mediaVersions: mediaVersions ?? this.mediaVersions,
libraryId: libraryId ?? this.libraryId,
libraryTitle: libraryTitle ?? this.libraryTitle,
audioLanguage: audioLanguage ?? this.audioLanguage,
subtitleLanguage: subtitleLanguage ?? this.subtitleLanguage,
subtitleMode: subtitleMode ?? this.subtitleMode,
trailerKey: trailerKey ?? this.trailerKey,
playlistItemId: playlistItemId ?? this.playlistItemId,
playQueueItemId: playQueueItemId ?? this.playQueueItemId,
subtype: subtype ?? this.subtype,
extraType: extraType ?? this.extraType,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
raw: raw ?? this.raw,
);
}
@override
Map<String, dynamic> toJson() {
return {
...super.toJson(),
if (editionTitle != null) 'editionTitle': editionTitle,
if (audienceRating != null) 'audienceRating': audienceRating,
if (ratingImage != null) 'ratingImage': ratingImage,
if (audienceRatingImage != null) 'audienceRatingImage': audienceRatingImage,
if (subtitleLanguage != null) 'subtitleLanguage': subtitleLanguage,
if (subtitleMode != null) 'subtitleMode': subtitleMode,
if (trailerKey != null) 'trailerKey': trailerKey,
if (playlistItemId != null) 'playlistItemId': playlistItemId,
if (playQueueItemId != null) 'playQueueItemId': playQueueItemId,
if (subtype != null) 'subtype': subtype,
if (extraType != null) 'extraType': extraType,
};
}
/// Restore a [PlexMediaItem] from a [toJson] payload. Reads the Plex-only
/// keys on top of the backend-neutral fields parsed by [_parseBaseFields].
factory PlexMediaItem.fromJson(Map<String, dynamic> json) {
final base = _parseBaseFields(json);
return PlexMediaItem(
id: base.id,
kind: base.kind,
guid: base.guid,
title: base.title,
titleSort: base.titleSort,
summary: base.summary,
tagline: base.tagline,
originalTitle: base.originalTitle,
editionTitle: json['editionTitle'] as String?,
studio: base.studio,
year: base.year,
originallyAvailableAt: base.originallyAvailableAt,
contentRating: base.contentRating,
parentId: base.parentId,
parentTitle: base.parentTitle,
parentThumbPath: base.parentThumbPath,
parentIndex: base.parentIndex,
index: base.index,
grandparentId: base.grandparentId,
grandparentTitle: base.grandparentTitle,
grandparentThumbPath: base.grandparentThumbPath,
grandparentArtPath: base.grandparentArtPath,
thumbPath: base.thumbPath,
artPath: base.artPath,
clearLogoPath: base.clearLogoPath,
backgroundSquarePath: base.backgroundSquarePath,
durationMs: base.durationMs,
viewOffsetMs: base.viewOffsetMs,
viewCount: base.viewCount,
lastViewedAt: base.lastViewedAt,
leafCount: base.leafCount,
viewedLeafCount: base.viewedLeafCount,
childCount: base.childCount,
addedAt: base.addedAt,
updatedAt: base.updatedAt,
rating: base.rating,
audienceRating: flexibleDouble(json['audienceRating']),
userRating: base.userRating,
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
genres: base.genres,
directors: base.directors,
writers: base.writers,
producers: base.producers,
countries: base.countries,
collections: base.collections,
labels: base.labels,
styles: base.styles,
moods: base.moods,
roles: base.roles,
mediaVersions: base.mediaVersions,
libraryId: base.libraryId,
libraryTitle: base.libraryTitle,
audioLanguage: base.audioLanguage,
subtitleLanguage: json['subtitleLanguage'] as String?,
subtitleMode: flexibleInt(json['subtitleMode']),
trailerKey: json['trailerKey'] as String?,
playlistItemId: flexibleInt(json['playlistItemId']),
playQueueItemId: flexibleInt(json['playQueueItemId']),
subtype: json['subtype'] as String?,
extraType: flexibleInt(json['extraType']),
serverId: base.serverId,
serverName: base.serverName,
raw: base.raw,
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,840 @@
part of '../../jellyfin_client.dart';
String _segment(String value) => Uri.encodeComponent(value);
List<Map<String, dynamic>> _itemsArray(Object? data) {
if (data is Map<String, dynamic>) {
final items = data['Items'];
if (items is List) return items.whereType<Map<String, dynamic>>().toList();
}
if (data is List) return data.whereType<Map<String, dynamic>>().toList();
return const [];
}
/// Slim field set for grid/list browsing — what the card UI actually
/// renders (title, year, watched badge, episode count for series),
/// plus `MediaSources` so the long-press "Play Version" gate matches
/// Plex's flow (Plex always inlines `Media[]`).
///
/// The real Jellyfin web client + Findroid skip explicit `Fields` for
/// list calls; we ask for the minimum extras needed to drive the
/// MediaItem mapper:
/// - `RecursiveItemCount`/`ChildCount` for series leaf count
/// - `UserData` is included in defaults but pinned for safety
/// - `PremiereDate` for sort-by-release-date and episode metadata
/// - `OriginalTitle`/`SortName` for sort + alphabetised display
/// - `Overview` so episode-list rows show their description
/// - `MediaSources` so the context menu can hide `Play Version` when
/// there's nothing to pick (cost: ~40ms per 50-item page)
///
/// Heavier fields (`People`, `Genres`, `Tags`, `Studios`, `Taglines`,
/// `ProviderIds`, `Chapters`) stay in [_detailFields] — together they
/// added ~6s to a 100-item Series page on a small home server.
const _browseFields =
'RecursiveItemCount,ChildCount,UserData,PremiereDate,OriginalTitle,SortName,Overview,MediaSources';
/// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows
/// only need title, thumbnail (`ImageTags['Primary']`), season/episode
/// index, and watched state. Title + indices come back without any
/// `Fields` request; we only need to ask for `UserData` for the
/// watched indicator. Drops `Overview` etc. so that even a thousand-
/// episode shounen show fits comfortably in one response.
const _queueFields = 'UserData';
/// Page size for [fetchClientSideEpisodeQueue]. Keeps each server response
/// bounded while still returning the full series queue.
const _episodeQueuePageSize = 200;
/// Full field set for the detail screen and the resume / next-up
/// pre-fetch paths. Mirrors what the Jellyfin web detail view requests.
const _detailFields =
'Overview,Genres,People,Studios,ProductionLocations,Tags,Taglines,DateCreated,DateLastSaved,'
'PremiereDate,RecursiveItemCount,ChildCount,UserData,MediaSources,OriginalTitle,SortName,'
// Chapters: Jellyfin returns them at the item level; the playback
// init flow plucks `raw['Chapters']` and feeds the seek-bar tick UI.
'Chapters,'
// Trickplay: per-resolution sprite-sheet manifest. The scrub-thumbnail
// loader reads `raw['Trickplay']` and computes tile URLs from it.
'Trickplay,'
// ProviderIds carries Tmdb/Imdb/Tvdb keys — required for Trakt + the
// unified tracker coordinator to scrobble Jellyfin items without
// any extra round-trip.
'ProviderIds';
mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
MediaServerHttpClient get _http;
MediaItem? _mapItem(Map<String, dynamic> json);
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
// Endpoint conventions follow what the official Jellyfin Kotlin SDK
// generates (cross-checked against the Findroid client). The SDK mixes
// `/Users/{userId}/...` for "user library" / "views" / "latest" / "single
// item" calls and `/Items?userId=...` for the generic list and resume
// endpoints. We mirror that exactly so requests hash the same way against
// proxy rules and rate limiters as a stock Jellyfin app.
@override
Future<List<MediaLibrary>> fetchLibraries() async {
final response = await _http.get('/Users/${_segment(connection.userId)}/Views');
throwIfHttpError(response);
final items = _itemsArray(response.data);
// Jellyfin surfaces the user's collection (BoxSet) and playlist roots as
// top-level views. We expose those as per-library tabs instead of
// standalone library entries — matches the Plex shape and avoids
// duplicating the same data in two navigation slots.
return items
.where((view) {
final ct = (view['CollectionType'] as String?)?.toLowerCase();
return ct != 'boxsets' && ct != 'playlists';
})
.map((view) => JellyfinMappers.library(view, serverId: serverId, serverName: serverName))
.whereType<MediaLibrary>()
.toList();
}
@override
Future<LibraryPage<MediaItem>> fetchLibraryContent(
String libraryId,
LibraryQuery query, {
AbortController? abort,
}) async {
final translator = JellyfinLibraryQueryTranslator(
userId: connection.userId,
parentId: libraryId,
fields: _browseFields,
);
final params = translator.toQueryParameters(query);
final response = await _http.get('/Items', queryParameters: params, abort: abort);
throwIfHttpError(response);
final data = response.data;
final items = _itemsArray(data);
final total = (data is Map<String, dynamic> ? data['TotalRecordCount'] as int? : null) ?? items.length;
return LibraryPage<MediaItem>(items: _mapItems(items), totalCount: total, offset: query.offset);
}
/// Jellyfin's `/Items/Filters` returns Genres / OfficialRatings / Tags /
/// Categories + values from `/Items/Filters` in a single call. Keys are
/// translated to Plex's filter naming so the existing filter-param map
/// round-trips through `_buildFilterParams` unchanged; the synthesised
/// `MediaFilter.key` is prefixed `jellyfin:` so FiltersBottomSheet can
/// recognise it as cached and skip the per-category value fetch.
@override
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId) async {
final response = await _http.get(
'/Items/Filters',
queryParameters: {'userId': connection.userId, 'ParentId': libraryId},
);
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) return LibraryFilterResult.empty;
List<String> stringList(Object? raw) {
if (raw is! List) return const [];
return raw.whereType<String>().where((s) => s.isNotEmpty).toList();
}
final raw = <String, List<String>>{
'genre': stringList(data['Genres']),
'contentRating': stringList(data['OfficialRatings']),
'tag': stringList(data['Tags']),
'year': (data['Years'] is List)
? (data['Years'] as List).whereType<num>().map((y) => y.toInt().toString()).toList()
: const <String>[],
};
const order = ['genre', 'year', 'contentRating', 'tag'];
final titles = {
'genre': t.libraries.filterCategories.genre,
'year': t.libraries.filterCategories.year,
'contentRating': t.libraries.filterCategories.contentRating,
'tag': t.libraries.filterCategories.tag,
};
final filters = <MediaFilter>[];
final values = <String, List<MediaFilterValue>>{};
for (final key in order) {
final entries = raw[key];
if (entries == null || entries.isEmpty) continue;
filters.add(
MediaFilter(filter: key, filterType: 'string', key: 'jellyfin:$key', title: titles[key] ?? key, type: 'filter'),
);
final sorted = List<String>.from(entries);
if (key == 'year') {
sorted.sort((a, b) => (int.tryParse(b) ?? 0).compareTo(int.tryParse(a) ?? 0));
} else {
sorted.sort();
}
values[key] = sorted.map((v) => MediaFilterValue(key: v, title: v)).toList();
}
return LibraryFilterResult(filters: filters, cachedValues: values);
}
/// Jellyfin has no `/sorts` listing endpoint, so this returns a hardcoded
/// list mirroring the Plex fallback set. Keys are the backend-neutral names
/// understood by [JellyfinLibraryQueryTranslator] (`title`, `addedAt`, …);
/// `_buildFilterParams` emits them as `addedAt:desc` etc., and
/// [LibraryQueryTranslator.parseSortParam] turns them back into a
/// [LibrarySort] before the translator maps them to Jellyfin's
/// `SortBy`/`SortOrder`.
@override
Future<List<MediaSort>> fetchSortOptions(String libraryId, {String? libraryType}) async {
return [
MediaSort(key: 'title', descKey: 'title:desc', title: t.libraries.sortLabels.title, defaultDirection: 'asc'),
MediaSort(
key: 'addedAt',
descKey: 'addedAt:desc',
title: t.libraries.sortLabels.dateAdded,
defaultDirection: 'desc',
),
MediaSort(
key: 'originallyAvailableAt',
descKey: 'originallyAvailableAt:desc',
title: t.libraries.sortLabels.releaseDate,
defaultDirection: 'desc',
),
MediaSort(key: 'rating', descKey: 'rating:desc', title: t.libraries.sortLabels.rating, defaultDirection: 'desc'),
MediaSort(
key: 'lastViewedAt',
descKey: 'lastViewedAt:desc',
title: t.libraries.sortLabels.lastPlayed,
defaultDirection: 'desc',
),
MediaSort(
key: 'viewCount',
descKey: 'viewCount:desc',
title: t.libraries.sortLabels.playCount,
defaultDirection: 'desc',
),
MediaSort(key: 'random', title: t.libraries.sortLabels.random, defaultDirection: 'asc'),
];
}
/// Jellyfin internalisation of the Plex-style filter map → [LibraryQuery]
/// translation. Routes through [fetchLibraryContent] so the
/// [JellyfinLibraryQueryTranslator] handles the actual `/Items` query.
///
/// [libraryKind] threads through so a "Shows" library returns Series rows
/// rather than the recursive episode expansion Jellyfin would otherwise
/// produce.
@override
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
String libraryId, {
required LibraryQuery query,
MediaKind? libraryKind,
AbortController? abort,
}) async {
// [libraryKind] takes priority over any kind already on [query] — the
// browse tab passes the library's actual kind (Series, Movie) to override
// a less specific value.
final effective = (libraryKind != null && libraryKind != MediaKind.unknown)
? query.copyWith(kind: libraryKind)
: query;
return fetchLibraryContent(libraryId, effective, abort: abort);
}
/// Synthesised 27-letter alphabet — Jellyfin has no equivalent of Plex's
/// `/firstCharacter` endpoint, so the UI treats the bar as a name-prefix
/// filter instead of a scroll affordance. Each entry has `size: 1` so
/// the alpha-jump helper renders it without trying to do offset math.
@override
Future<List<LibraryFirstCharacter>> fetchFirstCharacters(String libraryId, {Map<String, String>? filters}) async {
const letters = [
'#',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
return [for (final l in letters) LibraryFirstCharacter(key: l, title: l, size: 1)];
}
/// Queue a metadata refresh for the library. Jellyfin treats a library
/// view as an item, so we POST to `/Items/{id}/Refresh`. `FullRefresh`
/// re-pulls metadata from configured providers; `replaceAllMetadata=false`
/// preserves user edits — same UX as Plex's `refresh?force=1`.
@override
Future<void> refreshLibraryMetadata(String libraryId) async {
final response = await _http.post(
'/Items/${_segment(libraryId)}/Refresh',
queryParameters: {
'metadataRefreshMode': 'FullRefresh',
'imageRefreshMode': 'Default',
'replaceAllMetadata': 'false',
'replaceAllImages': 'false',
},
);
throwIfHttpError(response);
}
/// Jellyfin has no single-round-trip equivalent of Plex's
/// `?includeOnDeck=1`. We approximate it for shows by chaining a second
/// request to `/Shows/NextUp` filtered by `seriesId`. NextUp's defaults
/// (`enableResumable=true`, `disableFirstEpisode=false`) match Plex
/// OnDeck semantics: returns the resume episode when one exists, or S1E1
/// when the user hasn't started. Movies and other kinds short-circuit.
@override
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
final item = await fetchItem(id);
if (item == null || item.kind != MediaKind.show) {
return (item: item, onDeckEpisode: null);
}
final nextUp = await _safeFetchItemsArray('/Shows/NextUp', {
'seriesId': id,
'userId': connection.userId,
'Limit': '1',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
});
final onDeckEpisode = nextUp.isEmpty ? null : _mapItem(nextUp.first);
return (item: item, onDeckEpisode: onDeckEpisode);
}
@override
Future<MediaItem?> fetchItem(String id) async {
final endpoint = '/Users/${_segment(connection.userId)}/Items/${_segment(id)}';
// Contract:
// - 200 with parseable Map → MediaItem
// - 200 with non-Map body (HTML/text proxy page, empty) → null
// - 404 → null (item doesn't exist server-side)
// - 401/403/5xx → throw [MediaServerHttpException] so the UI can
// surface "auth required" / "server unavailable". Falling back to
// a cached row here would mislead the user into thinking they're
// still connected — explicit cache reads belong to the offline path.
// - Pure transport errors (no HTTP response) → fall back to cached row
// when present, otherwise rethrow.
if (isOfflineMode) {
final cached = await cache.get(cacheServerId, endpoint);
if (cached is Map<String, dynamic>) return _mapItem(cached);
return null;
}
try {
final response = await _http.get(endpoint, queryParameters: {'Fields': _detailFields});
throwIfHttpError(response);
final data = response.data;
if (data is! Map<String, dynamic>) return null;
try {
await cache.put(cacheServerId, endpoint, data);
} catch (e, st) {
appLogger.w('JellyfinClient.fetchItem cache write failed', error: e, stackTrace: st);
}
return _mapItem(data);
} on MediaServerHttpException catch (e) {
if (e.statusCode == 404) return null;
rethrow;
} catch (e) {
// Transport-layer failure: socket error, DNS, TLS, etc. Try cache.
appLogger.w('JellyfinClient.fetchItem network call failed', error: e);
try {
final cached = await cache.get(cacheServerId, endpoint);
if (cached is Map<String, dynamic>) return _mapItem(cached);
} catch (cacheError, st) {
appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st);
}
rethrow;
}
}
@override
Future<List<MediaItem>> fetchChildren(String parentId) async {
// Cache keys include userId so two users on the same server don't share
// per-user UserData (watched state) baked into the response.
final seasonsKey = '/Shows/$parentId/Seasons?userId=${connection.userId}';
final childrenKey = '/Items?ParentId=$parentId&userId=${connection.userId}';
if (isOfflineMode) {
final cachedSeasons = await cache.get(cacheServerId, seasonsKey);
if (cachedSeasons != null) {
final items = _itemsArray(cachedSeasons);
if (items.isNotEmpty) return _mapItems(items);
}
final cachedChildren = await cache.get(cacheServerId, childrenKey);
if (cachedChildren != null) {
return _mapItems(_itemsArray(cachedChildren));
}
return const [];
}
// For a series, the direct children are SEASONS (not the recursive
// episode expansion). Match Findroid: showsApi.getSeasons(seriesId)
// → /Shows/{seriesId}/Seasons. If the parent isn't a series this
// returns an empty list (or 404), so we fall through.
try {
final seasons = await _http.get(
'/Shows/${_segment(parentId)}/Seasons',
queryParameters: {'userId': connection.userId, 'Fields': _browseFields, ...jellyfinImageQueryParameters},
);
if (seasons.statusCode == 200) {
final data = seasons.data;
final items = _itemsArray(data);
if (items.isNotEmpty && data is Map<String, dynamic>) {
await cache.put(cacheServerId, seasonsKey, data);
return _mapItems(items);
}
}
} on MediaServerHttpException {
// Not a series — fall through to the generic ParentId query.
}
// Generic direct-children query: works for season → episodes,
// collection → items, etc.
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'ParentId': parentId,
'Fields': _browseFields,
'Limit': '500',
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final data = response.data;
if (data is Map<String, dynamic>) {
await cache.put(cacheServerId, childrenKey, data);
}
return _mapItems(_itemsArray(data));
}
/// All directly-playable descendants of [parentId] (Movies + Episodes),
/// recursively expanded. Used by the playback launcher so a collection
/// containing a Series plays its episodes instead of the unplayable
/// Series entry, and a playlist mixing both comes through the same path.
/// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems]
/// since those preserve the container shape (Series rows, PlaylistItemId).
///
/// No `Limit` — Jellyfin returns the entire list for this endpoint by
/// default, same precedent as [fetchClientSideEpisodeQueue].
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'ParentId': parentId,
'Recursive': 'true',
'IncludeItemTypes': 'Movie,Episode',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return _mapItems(_itemsArray(response.data));
}
/// All episodes of a series in air order, optimised for queue-building.
/// Uses [_queueFields] (only `UserData`) instead of the browse field
/// set so the response stays small even for shows with thousands of
/// episodes.
///
/// Paged in [_episodeQueuePageSize] chunks so long-running shows still get
/// a complete client-side next/previous queue without one huge response.
@override
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async {
final all = <MediaItem>[];
var startIndex = 0;
int? totalRecordCount;
while (totalRecordCount == null || startIndex < totalRecordCount) {
final response = await _http.get(
'/Shows/${_segment(seriesId)}/Episodes',
queryParameters: {
'userId': connection.userId,
'Fields': _queueFields,
'StartIndex': '$startIndex',
'Limit': '$_episodeQueuePageSize',
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final data = response.data;
final page = _mapItems(_itemsArray(data));
all.addAll(page);
if (data is Map<String, dynamic>) {
final rawTotal = data['TotalRecordCount'];
if (rawTotal is int) totalRecordCount = rawTotal;
}
if (page.length < _episodeQueuePageSize) break;
startIndex += page.length;
}
return all;
}
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 30}) async {
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'SearchTerm': query,
'Recursive': 'true',
'Limit': limit.toString(),
'IncludeItemTypes': 'Movie,Series,Episode',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return _mapItems(_itemsArray(response.data));
}
@override
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50}) async {
// Matches userLibraryApi.getLatestMedia in the Jellyfin SDK.
final response = await _http.get(
'/Users/${_segment(connection.userId)}/Items/Latest',
queryParameters: {
'Limit': limit.toString(),
'Fields': _browseFields,
'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final data = response.data;
// Latest returns a bare array, not an Items wrapper.
if (data is List) {
return _mapItems(data.whereType<Map<String, dynamic>>());
}
return _mapItems(_itemsArray(data));
}
@override
Future<List<MediaItem>> fetchContinueWatching({int count = 20}) async {
final results = await Future.wait([
_fetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'Limit': count.toString(),
'Fields': _browseFields,
'MediaTypes': 'Video',
'Recursive': 'true',
...jellyfinImageQueryParameters,
}),
_safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'Limit': count.toString(),
'Fields': _browseFields,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}),
]);
return _mergeContinueWatchingAndNextUp(resume: _mapItems(results[0]), nextUp: _mapItems(results[1]), limit: count);
}
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async {
// Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the
// home rows from Latest plus optional playback rows. The richer Plex Discover surface
// is intentionally left untranslated — see ServerCapabilities.richHubs.
final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'Fields': _browseFields,
'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
});
if (!includePlaybackHubs) {
final latest = await latestFuture;
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'home.recent',
title: t.discover.recentlyAdded,
type: 'mixed',
items: latest,
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
final results = await Future.wait([
latestFuture,
_safeFetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'Limit': limit.toString(),
'Fields': _browseFields,
'MediaTypes': 'Video',
'Recursive': 'true',
...jellyfinImageQueryParameters,
}),
_safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'Limit': limit.toString(),
'Fields': _browseFields,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}),
]);
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'home.continue',
title: t.discover.continueWatching,
type: 'mixed',
items: results[1],
serverId: serverId,
serverName: serverName,
),
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'home.nextup',
title: t.discover.nextUp,
type: 'episode',
items: results[2],
serverId: serverId,
serverName: serverName,
),
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'home.recent',
title: t.discover.recentlyAdded,
type: 'mixed',
items: results[0],
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
@override
Future<List<MediaHub>> fetchLibraryHubs(
String libraryId, {
required String libraryName,
int limit = 10,
bool includePlaybackHubs = true,
}) async {
// Mirror the Jellyfin web client's per-library "Suggestions" tab:
// Continue Watching + Next Up (TV libraries) + Recently Added.
//
// Issued in parallel so the recommended tab loads in one round-trip.
// We probe the library kind first to decide whether to ask for NextUp
// — querying it for a movie library is harmless (returns []), but
// skipping the request keeps the wire chatter tighter.
final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'ParentId': libraryId,
'Fields': _browseFields,
...jellyfinImageQueryParameters,
});
if (!includePlaybackHubs) {
final latest = await latestFuture;
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.recent',
title: t.discover.recentlyAddedIn(library: libraryName),
type: 'mixed',
items: latest,
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
final results = await Future.wait([
latestFuture,
_safeFetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'ParentId': libraryId,
'Limit': limit.toString(),
'Fields': _browseFields,
'MediaTypes': 'Video',
'Recursive': 'true',
...jellyfinImageQueryParameters,
}),
_safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'ParentId': libraryId,
'Limit': limit.toString(),
'Fields': _browseFields,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
}),
]);
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.continue',
title: t.discover.continueWatchingIn(library: libraryName),
type: 'mixed',
items: results[1],
serverId: serverId,
serverName: serverName,
),
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.nextup',
title: t.discover.nextUpIn(library: libraryName),
type: 'episode',
items: results[2],
serverId: serverId,
serverName: serverName,
),
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.recent',
title: t.discover.recentlyAddedIn(library: libraryName),
type: 'mixed',
items: results[0],
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
/// Re-run the synthetic hub query without the preview limit so the
/// hub-detail screen can render the full list. Branches on the
/// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]:
/// `home.recent` / `library.{id}.recent` → Latest, `*.continue` → Resume,
/// `*.nextup` → NextUp. Unknown ids return an empty list.
@override
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) async {
final effectiveLimit = (limit ?? 50).toString();
String? parentId;
if (hubId.startsWith('library.')) {
final rest = hubId.substring('library.'.length);
final dot = rest.lastIndexOf('.');
if (dot > 0) parentId = rest.substring(0, dot);
}
final tail = hubId.split('.').last;
final List<Map<String, dynamic>> items;
switch (tail) {
case 'recent':
items = await _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': effectiveLimit,
'Fields': _browseFields,
if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
});
break;
case 'continue':
items = await _safeFetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'Limit': effectiveLimit,
'Fields': _browseFields,
'Recursive': 'true',
if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video',
...jellyfinImageQueryParameters,
});
break;
case 'nextup':
items = await _safeFetchItemsArray('/Shows/NextUp', {
'userId': connection.userId,
'Limit': effectiveLimit,
'Fields': _browseFields,
'ParentId': ?parentId,
'EnableResumable': 'false',
'EnableTotalRecordCount': 'false',
...jellyfinImageQueryParameters,
});
break;
default:
return const [];
}
return _mapItems(items);
}
@override
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10}) async {
final response = await _http.get(
'/Items/${_segment(id)}/Similar',
queryParameters: {
'userId': connection.userId,
'Limit': count.toString(),
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'item.$id.similar',
title: 'More Like This',
type: 'mixed',
items: _itemsArray(response.data),
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
List<MediaItem> _mergeContinueWatchingAndNextUp({
required List<MediaItem> resume,
required List<MediaItem> nextUp,
required int limit,
}) {
if (limit <= 0) return const [];
final result = <MediaItem>[];
final seenIds = <String>{};
final seenSeriesIds = <String>{};
void add(MediaItem item) {
if (!seenIds.add(item.id)) return;
final seriesId = item.kind == MediaKind.episode ? item.grandparentId : null;
if (seriesId != null && !seenSeriesIds.add(seriesId)) return;
result.add(item);
}
for (final item in resume) {
add(item);
if (result.length >= limit) return result;
}
for (final item in nextUp) {
add(item);
if (result.length >= limit) return result;
}
return result;
}
Future<List<Map<String, dynamic>>> _fetchItemsArray(String path, Map<String, dynamic> queryParameters) async {
final response = await _http.get(path, queryParameters: queryParameters);
throwIfHttpError(response);
return _itemsArray(response.data);
}
Future<List<Map<String, dynamic>>> _safeFetchItemsArray(String path, Map<String, dynamic> queryParameters) async {
try {
final response = await _http.get(path, queryParameters: queryParameters);
throwIfHttpError(response);
final data = response.data;
if (data is List) {
return data.whereType<Map<String, dynamic>>().toList();
}
return _itemsArray(data);
} catch (e, st) {
appLogger.w('JellyfinClient: $path failed (treating as empty)', error: e, stackTrace: st);
return const [];
}
}
}
@@ -0,0 +1,109 @@
part of '../../jellyfin_client.dart';
mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
MediaServerHttpClient get _http;
Map<String, List<MediaItem>> get _collectionItemsCache;
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
@override
Future<List<MediaItem>> fetchCollections(String libraryId) async {
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'ParentId': libraryId,
'IncludeItemTypes': 'BoxSet',
'Recursive': 'true',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return _mapItems(_itemsArray(response.data));
}
/// Jellyfin has no pagination knob for collection children, so the first
/// call materialises the full list via [fetchChildren] and subsequent
/// paged calls slice from the same in-memory copy ([_collectionItemsCache]).
/// The [abort] hook is unused on this backend — the slice path is
/// synchronous and the underlying fetch is short-lived.
@override
Future<LibraryPage<MediaItem>> fetchCollectionPage(
String collectionId, {
int? start,
int? size,
AbortController? abort,
}) async {
final cached = _collectionItemsCache[collectionId] ?? await _loadAndCacheCollectionItems(collectionId);
final s = start ?? 0;
final fullSize = cached.length;
final from = s.clamp(0, fullSize);
final to = (size == null) ? fullSize : (s + size).clamp(0, fullSize);
return LibraryPage<MediaItem>(items: cached.sublist(from, to), totalCount: fullSize, offset: s);
}
Future<List<MediaItem>> _loadAndCacheCollectionItems(String collectionId) async {
final items = await fetchChildren(collectionId);
_collectionItemsCache[collectionId] = items;
return items;
}
@override
Future<String?> createCollection({
required String libraryId,
required String title,
required List<MediaItem> items,
MediaKind? itemKind,
}) async {
// ParentId is optional on Jellyfin's `/Collections` endpoint — when
// omitted the server picks a default BoxSet root. We pass libraryId so
// the new collection lives in the same library as the seeded items.
final response = await _http.post(
'/Collections',
queryParameters: {
'Name': title,
if (items.isNotEmpty) 'Ids': items.map((i) => i.id).join(','),
if (libraryId.isNotEmpty) 'ParentId': libraryId,
},
);
throwIfHttpError(response);
final data = response.data;
return data is Map<String, dynamic> ? data['Id'] as String? : null;
}
@override
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final response = await _http.post(
'/Collections/${_segment(collectionId)}/Items',
queryParameters: {'Ids': items.map((i) => i.id).join(',')},
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> removeFromCollection({required String collectionId, required MediaItem item}) async {
final response = await _http.delete(
'/Collections/${_segment(collectionId)}/Items',
queryParameters: {'Ids': item.id},
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> deleteCollection(MediaItem collection) async {
final response = await _http.delete('/Items/${_segment(collection.id)}');
throwIfHttpError(response);
return true;
}
@override
Future<bool> deleteMediaItem(MediaItem item) async {
final response = await _http.delete('/Items/${_segment(item.id)}');
throwIfHttpError(response);
return true;
}
}
@@ -0,0 +1,78 @@
part of '../../jellyfin_client.dart';
mixin _JellyfinFileInfoMethods on MediaServerCacheMixin {
@override
Future<MediaFileInfo?> getFileInfo(MediaItem item) async {
// Browse responses already include `MediaSources` (see [_browseFields]).
// Re-fetch via [fetchItem] only if the inline data isn't available.
final raw = item.raw is Map<String, dynamic> ? item.raw as Map<String, dynamic> : null;
Map<String, dynamic>? itemJson = raw;
if (itemJson == null || itemJson['MediaSources'] is! List) {
final fresh = await fetchItem(item.id);
itemJson = fresh?.raw is Map<String, dynamic> ? fresh!.raw as Map<String, dynamic> : null;
}
if (itemJson == null) return null;
return _buildFileInfoFromJellyfinItem(itemJson);
}
MediaFileInfo? _buildFileInfoFromJellyfinItem(Map<String, dynamic> json) {
final sources = json['MediaSources'];
if (sources is! List || sources.isEmpty) return null;
final source = sources.first;
if (source is! Map<String, dynamic>) return null;
final parsed = walkStreams(source['MediaStreams'] as List?, const JellyfinFileInfoStreamReader());
final videoStream = parsed.videoStream;
final audioStream = parsed.audioStream;
final audioTracks = parsed.audioTracks;
final subtitleTracks = parsed.subtitleTracks;
final width = videoStream?['Width'] as int?;
final height = videoStream?['Height'] as int?;
final aspectRatioString = videoStream?['AspectRatio'] as String?;
double? aspectRatio;
if (aspectRatioString != null && aspectRatioString.contains(':')) {
final parts = aspectRatioString.split(':');
final num = double.tryParse(parts[0]);
final den = double.tryParse(parts[1]);
if (num != null && den != null && den != 0) aspectRatio = num / den;
}
aspectRatio ??= (width != null && height != null && height != 0) ? width / height : null;
final runtimeTicks = source['RunTimeTicks'] as int?;
final durationMs = runtimeTicks != null ? (runtimeTicks ~/ 10000) : null;
final bitrateBps = source['Bitrate'] as int?;
final videoBitrateBps = videoStream?['BitRate'] as int?;
return MediaFileInfo(
container: source['Container'] as String?,
videoCodec: videoStream?['Codec'] as String?,
videoResolution: resolutionLabelFromDimensions(width, height),
videoFrameRate: videoStream?['RealFrameRate']?.toString() ?? videoStream?['AverageFrameRate']?.toString(),
videoProfile: videoStream?['Profile'] as String?,
width: width,
height: height,
aspectRatio: aspectRatio,
// Plex stores bitrate as kbps; Jellyfin returns bps. Normalise to kbps.
bitrate: bitrateBps != null ? bitrateBps ~/ 1000 : null,
duration: durationMs,
audioCodec: audioStream?['Codec'] as String?,
audioProfile: audioStream?['Profile'] as String?,
audioChannels: audioStream?['Channels'] as int?,
filePath: source['Path'] as String?,
fileSize: source['Size'] as int?,
colorSpace: videoStream?['ColorSpace'] as String?,
colorRange: videoStream?['ColorRange'] as String?,
colorPrimaries: videoStream?['ColorPrimaries'] as String?,
chromaSubsampling: null,
frameRate:
(videoStream?['RealFrameRate'] as num?)?.toDouble() ?? (videoStream?['AverageFrameRate'] as num?)?.toDouble(),
bitDepth: videoStream?['BitDepth'] as int?,
videoBitrate: videoBitrateBps != null ? videoBitrateBps ~/ 1000 : null,
audioChannelLayout: audioStream?['ChannelLayout'] as String?,
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
);
}
}
@@ -0,0 +1,111 @@
part of '../../jellyfin_client.dart';
mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0});
String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId});
Future<Map<String, dynamic>?> getPlaybackInfo(
String itemId, {
int maxStreamingBitrate = 100000000,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
});
String _withApiKey(String urlOrPath);
@override
String thumbnailUrl(String? path, {int? width, int? height}) {
if (path == null || path.isEmpty) return '';
final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: path);
final params = Map<String, String>.from(uri.queryParameters);
if (width != null && !params.containsKey('maxWidth') && !params.containsKey('MaxWidth')) {
params['maxWidth'] = '$width';
}
if (height != null && !params.containsKey('maxHeight') && !params.containsKey('MaxHeight')) {
params['maxHeight'] = '$height';
}
params.putIfAbsent('api_key', () => connection.accessToken);
return uri.replace(queryParameters: params).toString();
}
/// Jellyfin doesn't expose an external-URL proxy endpoint comparable to
/// Plex's `/photo/:/transcode?url=...`. External URLs pass through.
@override
String externalImageUrl(String url, {int? width, int? height}) => url;
@override
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0}) async {
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex);
if (bundle == null) return buildDirectStreamUrl(item.id);
final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != item.id
? bundle.selectedSourceId
: null;
return buildDirectStreamUrl(item.id, container: bundle.container, mediaSourceId: pinnedSourceId);
}
@override
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0}) async {
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex);
final selectedSourceId = bundle?.selectedSourceId;
final pinnedSourceId = selectedSourceId != null && selectedSourceId != item.id ? selectedSourceId : null;
// Direct-stream the selected original file. Jellyfin's `Static=true`
// skips the transcoder so the byte-for-byte source lands on disk.
final videoUrl = buildDirectStreamUrl(item.id, container: bundle?.container, mediaSourceId: pinnedSourceId);
// External subtitle sidecars are listed in the per-source MediaStreams.
// PlaybackInfo gives us the canonical view including DeliveryUrl when
// the server has pre-computed one; fall back to the documented stream
// URL pattern otherwise.
final subtitles = <DownloadSubtitleSpec>[];
final pbInfo = await getPlaybackInfo(item.id);
if (pbInfo != null) {
final sources = pbInfo['MediaSources'];
if (sources is List && sources.length > mediaIndex) {
final source = sources[mediaIndex];
if (source is Map<String, dynamic>) {
final mediaSourceId = (source['Id'] as String?) ?? item.id;
final streams = source['MediaStreams'];
if (streams is List) {
for (final raw in streams) {
if (raw is! Map<String, dynamic>) continue;
if (raw['Type'] != 'Subtitle') continue;
final fields = parseJellyfinStreamFields(raw);
if (!fields.isExternal) continue;
final index = raw['Index'];
if (index is! int) continue;
final codec = fields.codec?.toLowerCase();
final delivery = fields.deliveryUrl;
final url = _withApiKey(
delivery != null && delivery.isNotEmpty
? delivery
: '/Videos/${_segment(item.id)}/${_segment(mediaSourceId)}/Subtitles/$index/${_segment('Stream.${codec ?? 'srt'}')}',
);
subtitles.add(
DownloadSubtitleSpec(
id: index,
url: url,
codec: codec,
language: fields.language,
languageCode: fields.languageCode,
forced: fields.isForced,
displayTitle: fields.displayTitle,
),
);
}
}
}
}
}
return DownloadResolution(videoUrl: videoUrl, externalSubtitles: subtitles);
}
@override
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) {
// Jellyfin paths flow through `_absolutizeImagePath` at the mapper
// boundary, so artwork fields on the [MediaItem] are already absolute
// URLs. buildArtworkSpecs strips auth query params from localKey so the
// storage layer never hashes or persists access tokens.
return buildArtworkSpecs(item, (path) => path);
}
}
@@ -1,7 +1,152 @@
part of '../jellyfin_client.dart';
part of '../../jellyfin_client.dart';
/// Jellyfin implementation of [LiveTvSupport]. Wraps the existing
/// `fetchLiveTvChannels` / `fetchLiveTvPrograms` / `buildDirectStreamUrl`.
mixin _JellyfinLiveTvMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
MediaServerHttpClient get _http;
String? _absolutizeImagePath(String? path);
Future<List<Map<String, dynamic>>> _safeFetchItemsArray(String path, Map<String, dynamic> queryParameters);
/// Returns `true` when this server has Live TV configured (channels
/// available). Probes `/LiveTv/Channels?limit=1`. Used by [MultiServerProvider]
/// to gate the Live TV menu.
Future<bool> hasLiveTv() async {
try {
final response = await _http.get(
'/LiveTv/Channels',
queryParameters: {'limit': '1', 'userId': connection.userId},
);
if (response.statusCode != 200) return false;
final data = response.data;
if (data is Map<String, dynamic>) {
final total = data['TotalRecordCount'];
if (total is int) return total > 0;
final items = data['Items'];
if (items is List) return items.isNotEmpty;
}
return false;
} catch (e) {
appLogger.d('Jellyfin Live TV probe failed', error: e);
return false;
}
}
/// Fetch the user's Live TV channel list. Each `BaseItemDto` of type
/// `TvChannel` is mapped to a [LiveTvChannel].
Future<List<LiveTvChannel>> fetchLiveTvChannels() async {
final items = await _safeFetchItemsArray('/LiveTv/Channels', {
'userId': connection.userId,
'enableImages': 'true',
'enableUserData': 'true',
'sortBy': 'SortName',
'sortOrder': 'Ascending',
});
return items.map(_channelFromJson).toList();
}
/// EPG / programs grid. [channelIds] scopes to specific channels (when
/// empty, the server returns programs across all channels). [beginsAt] /
/// [endsAt] are epoch seconds and bound the time window — Jellyfin uses
/// ISO 8601 strings on the wire.
Future<List<LiveTvProgram>> fetchLiveTvPrograms({
List<String> channelIds = const [],
int? beginsAt,
int? endsAt,
}) async {
DateTime? toDt(int? epoch) => epoch == null ? null : DateTime.fromMillisecondsSinceEpoch(epoch * 1000, isUtc: true);
final params = <String, dynamic>{
'userId': connection.userId,
'enableImages': 'true',
'sortBy': 'StartDate',
'sortOrder': 'Ascending',
if (channelIds.isNotEmpty) 'channelIds': channelIds.join(','),
if (beginsAt != null) 'minStartDate': toDt(beginsAt)!.toIso8601String(),
if (endsAt != null) 'maxStartDate': toDt(endsAt)!.toIso8601String(),
};
final items = await _safeFetchItemsArray('/LiveTv/Programs', params);
return items.map(_programFromJson).toList();
}
LiveTvProgram _programFromJson(Map<String, dynamic> json) {
final id = json['Id'] as String?;
int? toEpochSec(dynamic raw) {
if (raw is! String || raw.isEmpty) return null;
final ms = DateTime.tryParse(raw)?.toUtc().millisecondsSinceEpoch;
return ms != null ? ms ~/ 1000 : null;
}
final tags = json['ImageTags'];
String? primaryTag;
if (tags is Map<String, dynamic>) {
primaryTag = tags['Primary'] as String?;
}
final thumbPath = (id != null && primaryTag != null)
? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}')
: null;
return LiveTvProgram(
key: id,
ratingKey: id,
guid: null,
title: json['Name'] as String? ?? 'Unknown Program',
summary: json['Overview'] as String?,
type: 'episode',
year: (json['ProductionYear'] as num?)?.toInt(),
beginsAt: toEpochSec(json['StartDate']),
endsAt: toEpochSec(json['EndDate']),
grandparentTitle: json['SeriesName'] as String?,
parentTitle: json['SeasonName'] as String?,
index: (json['IndexNumber'] as num?)?.toInt(),
parentIndex: (json['ParentIndexNumber'] as num?)?.toInt(),
thumb: thumbPath,
art: null,
channelIdentifier: json['ChannelId'] as String?,
channelCallSign: json['ChannelCallSign'] as String? ?? json['ChannelName'] as String?,
live: json['IsLive'] as bool?,
premiere: json['IsPremiere'] as bool?,
);
}
LiveTvChannel _channelFromJson(Map<String, dynamic> json) {
final id = json['Id'] as String? ?? '';
final name = json['Name'] as String?;
final number = json['Number'] as String? ?? json['ChannelNumber'] as String?;
final tags = json['ImageTags'];
String? primaryTag;
if (tags is Map<String, dynamic>) {
primaryTag = tags['Primary'] as String?;
}
final thumbPath = primaryTag != null
? _absolutizeImagePath('/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(primaryTag)}')
: null;
return LiveTvChannel(
key: id,
identifier: id,
callSign: json['CallSign'] as String?,
title: name,
thumb: thumbPath,
art: null,
number: number,
hd: false,
lineup: null,
slug: null,
drm: null,
serverId: serverId,
serverName: serverName,
);
}
@override
LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this as JellyfinClient);
/// Toggle the per-user `IsFavorite` flag for [itemId]. Used by the live-TV
/// favorite-channel adapter; works on any Jellyfin item.
Future<void> _setItemFavorite(String itemId, bool isFavorite) async {
final path = '/Users/${_segment(connection.userId)}/FavoriteItems/${_segment(itemId)}';
final response = isFavorite ? await _http.post(path) : await _http.delete(path);
throwIfHttpError(response);
}
}
/// Adapter from [LiveTvSupport] to Jellyfin channel/program helpers.
class _JellyfinLiveTvSupport implements LiveTvSupport {
final JellyfinClient _client;
_JellyfinLiveTvSupport(this._client);
@@ -0,0 +1,544 @@
part of '../../jellyfin_client.dart';
mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
MediaServerHttpClient get _http;
/// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters
/// at the item level (`raw['Chapters']`) and native skip segments through a
/// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort
/// so older servers still use chapter title fallback.
@override
Future<PlaybackExtras> fetchPlaybackExtras(
String itemId, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
bool forceRefresh = false,
}) async {
final item = await fetchItem(itemId);
final markers = item == null ? const <MediaMarker>[] : await _fetchMediaSegmentMarkers(itemId);
return jellyfinPlaybackExtrasFromRaw(
item?.raw,
itemId,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
markers: markers,
);
}
@override
Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
String itemId, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
}) async {
final item = await cache.getMetadata(cacheServerId, itemId);
if (item == null) return null;
final markers = await _fetchCachedMediaSegmentMarkers(itemId);
return jellyfinPlaybackExtrasFromRaw(
item.raw,
itemId,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
markers: markers,
);
}
@override
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId) async {
final item = await cache.getMetadata(cacheServerId, itemId);
final raw = item?.raw;
if (raw is! Map<String, dynamic>) return null;
final sources = raw['MediaSources'];
if (sources is! List || sources.isEmpty) return null;
final first = sources.first;
if (first is! Map<String, dynamic>) return null;
return jellyfinMediaSourceToMediaSourceInfo(first, chapters: raw['Chapters'], trickplay: raw['Trickplay']);
}
@override
Future<ScrubPreviewSource?> createScrubPreviewSource({
required MediaItem item,
required MediaSourceInfo mediaSource,
}) async {
if (!capabilities.scrubThumbnails) return null;
final manifest = mediaSource.trickplayByWidth;
if (manifest == null || manifest.isEmpty) return null;
return JellyfinTrickplayService.create(
client: this as JellyfinClient,
itemId: item.id,
mediaSourceId: mediaSource.mediaSourceId,
manifest: manifest,
);
}
Future<List<MediaMarker>> _fetchMediaSegmentMarkers(String itemId) async {
final endpoint = JellyfinApiCache.mediaSegmentsEndpoint(itemId);
try {
return await fetchWithCacheFallback<List<MediaMarker>>(
cacheKey: endpoint,
networkCall: () async {
final response = await _http.get(endpoint);
if (response.statusCode == 404) {
return MediaServerResponse(statusCode: 200, headers: response.headers, requestUri: response.requestUri);
}
throwIfHttpError(response);
return response;
},
parseCache: jellyfinMediaSegmentsToMarkers,
parseResponse: (response) => jellyfinMediaSegmentsToMarkers(response.data),
) ??
const [];
} on MediaServerHttpException catch (e) {
if (e.statusCode != 404) {
appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e);
}
return const [];
} catch (e) {
appLogger.d('JellyfinClient.fetchPlaybackExtras media segments unavailable', error: e);
return const [];
}
}
Future<List<MediaMarker>> _fetchCachedMediaSegmentMarkers(String itemId) async {
try {
final data = await cache.get(cacheServerId, JellyfinApiCache.mediaSegmentsEndpoint(itemId));
return jellyfinMediaSegmentsToMarkers(data);
} catch (e) {
appLogger.d('JellyfinClient.fetchPlaybackExtras cached media segments unavailable', error: e);
return const [];
}
}
String _withApiKey(String urlOrPath) {
final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: urlOrPath);
final params = Map<String, String>.from(uri.queryParameters)..['api_key'] = connection.accessToken;
return uri.replace(queryParameters: params).toString();
}
/// Jellyfin playback URL resolution.
///
/// Two paths:
/// * `qualityPreset.isOriginal` → direct stream
/// (`/Videos/{id}/stream?Static=true&api_key=...`).
/// * non-original preset → POST `/Items/{id}/PlaybackInfo` with the
/// preset's bitrate and use the server-computed `TranscodingUrl`
/// from the returned `MediaSources` entry. Falls back to direct stream
/// when the server didn't provide a transcode URL (e.g. direct play
/// fits the cap) or the negotiation request failed.
///
/// The returned `MediaSourceInfo` is what the player uses for track-picker
/// labels and auto-track selection by language.
///
/// Throws [PlaybackException] when the item is missing or has no
/// `MediaSources`.
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
final metadata = options.metadata;
final bundle = await fetchPlaybackBundle(metadata.id, sourceIndex: options.selectedMediaIndex);
if (bundle == null) {
throw PlaybackException('Item ${metadata.id} returned no MediaSources');
}
var mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
bundle.selectedSource,
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
var externalSubtitles = _buildExternalSubtitles(metadata.id, bundle.selectedSourceId, mediaInfo);
// Only forward MediaSourceId when there's actually more than one source —
// single-source items have `MediaSourceId == itemId` so the param is a
// no-op there but adds clutter to logs.
final pinnedSourceId = bundle.selectedSourceId != null && bundle.selectedSourceId != metadata.id
? bundle.selectedSourceId
: null;
String? videoUrl;
String? playSessionId;
var playMethod = 'DirectPlay';
var isTranscoding = false;
TranscodeFallbackReason? fallbackReason;
final preset = options.qualityPreset;
if (!preset.isOriginal && preset.videoBitrateKbps != null) {
final maxBps = preset.videoBitrateKbps! * 1000;
final negotiation = await getPlaybackInfo(
metadata.id,
maxStreamingBitrate: maxBps,
mediaSourceId: bundle.selectedSourceId,
audioStreamIndex: options.selectedAudioStreamId,
);
if (negotiation == null) {
fallbackReason = TranscodeFallbackReason.decisionFailed;
} else {
final sources = negotiation['MediaSources'];
Map<String, dynamic>? chosenSource;
if (sources is List && sources.isNotEmpty) {
for (final src in sources) {
if (src is Map<String, dynamic> && src['Id'] == bundle.selectedSourceId) {
chosenSource = src;
break;
}
}
chosenSource ??= sources.first is Map<String, dynamic> ? sources.first as Map<String, dynamic> : null;
}
final chosenStreams = chosenSource?['MediaStreams'];
if (chosenSource != null && chosenStreams is List && chosenStreams.isNotEmpty) {
mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
chosenSource,
chapters: bundle.chapters,
trickplay: bundle.trickplay,
);
externalSubtitles = _buildExternalSubtitles(
metadata.id,
chosenSource['Id'] as String? ?? bundle.selectedSourceId,
mediaInfo,
);
}
final transcodingUrl = chosenSource?['TranscodingUrl'];
if (transcodingUrl is String && transcodingUrl.isNotEmpty) {
// TranscodingUrl is server-relative and already encodes container,
// codecs, MediaSourceId, and PlaySessionId; we just append the
// api_key for auth.
playSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId'];
final negotiatedPlaySessionId = negotiation['PlaySessionId'];
if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) {
playSessionId = negotiatedPlaySessionId;
}
videoUrl = _withApiKey(transcodingUrl);
playMethod = 'Transcode';
isTranscoding = true;
} else {
final directStreamUrl = chosenSource?['DirectStreamUrl'];
if (directStreamUrl is String && directStreamUrl.isNotEmpty) {
playSessionId = Uri.tryParse(directStreamUrl)?.queryParameters['PlaySessionId'];
final negotiatedPlaySessionId = negotiation['PlaySessionId'];
if ((playSessionId == null || playSessionId.isEmpty) && negotiatedPlaySessionId is String) {
playSessionId = negotiatedPlaySessionId;
}
videoUrl = _withApiKey(directStreamUrl);
playMethod = 'DirectStream';
} else {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
}
}
}
videoUrl ??= buildDirectStreamUrl(metadata.id, container: bundle.container, mediaSourceId: pinnedSourceId);
return PlaybackInitializationResult(
availableVersions: bundle.availableVersions,
videoUrl: videoUrl,
mediaInfo: mediaInfo,
externalSubtitles: externalSubtitles,
isOffline: false,
isTranscoding: isTranscoding,
fallbackReason: fallbackReason,
activeAudioStreamId: isTranscoding ? options.selectedAudioStreamId : null,
playSessionId: playSessionId,
playMethod: playMethod,
);
}
String? _jellyfinSubtitleFallbackPath(String itemId, String? mediaSourceId, MediaSubtitleTrack track) {
final sourceId = mediaSourceId;
final streamIndex = track.index ?? track.id;
final codec = track.codec;
if (sourceId == null || codec == null || codec.isEmpty) return null;
final path = Uri(
pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'],
).path;
return path.startsWith('/') ? path : '/$path';
}
List<SubtitleTrack> _buildExternalSubtitles(String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo) {
final externalSubtitles = <SubtitleTrack>[];
for (final track in mediaInfo.subtitleTracks) {
if (!track.isExternal) continue;
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
if (path == null) continue;
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
// absolute URL with the api_key query param.
final url = _withApiKey(path);
externalSubtitles.add(
SubtitleTrack.uri(
url,
title:
cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
cleanTrackMetadataValue(track.language),
language: cleanTrackMetadataValue(track.languageCode),
),
);
}
return externalSubtitles;
}
/// Internal accessor for [PlaybackInitializationService]. Returns the
/// chosen `MediaSource` JSON, every available source's [MediaVersion],
/// and the item's `Chapters` array. One round-trip vs. fetchItem + raw
/// extraction at the call site.
///
/// Returns `null` when the item doesn't exist or has no `MediaSources`.
/// [sourceIndex] is clamped to the valid range — out-of-bounds requests
/// fall back to source 0 to mirror Plex's `parseVideoPlaybackDataFromJson`.
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(String itemId, {int sourceIndex = 0}) async {
final item = await fetchItem(itemId);
final raw = item?.raw;
if (raw is! Map<String, dynamic>) return null;
final sources = raw['MediaSources'];
if (sources is! List || sources.isEmpty) return null;
final availableVersions = jellyfinSourcesToVersions(sources);
var index = sourceIndex;
if (index < 0 || index >= sources.length) index = 0;
final source = sources[index];
if (source is! Map<String, dynamic>) return null;
final chapters = raw['Chapters'];
return JellyfinPlaybackBundle(
availableVersions: availableVersions,
selectedSource: source,
chapters: chapters is List ? chapters : const [],
container: source['Container'] as String?,
selectedSourceId: source['Id'] as String?,
trickplay: raw['Trickplay'],
);
}
/// Direct-stream URL for [itemId]. Best for files the device can play
/// natively. Adds `?Static=true` to skip the transcoder and
/// `&api_key=...` so the request authenticates without a header.
///
/// Pass [mediaSourceId] to stream a non-default alternate version. When the
/// item only has a single MediaSource, [mediaSourceId] equals [itemId] and
/// can be omitted; for items with multiple versions Jellyfin uses the
/// param to pick which file to serve.
String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) {
return buildJellyfinDirectStreamUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
deviceId: connection.deviceId,
itemId: itemId,
container: container,
mediaSourceId: mediaSourceId,
);
}
/// Trickplay sprite-sheet URL. [width] picks one of the resolutions
/// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based
/// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails).
/// Pass [mediaSourceId] when the item has more than one source so the
/// server returns the matching version's tiles.
String buildTrickplayTileUrl(String itemId, int width, int sheetIndex, {String? mediaSourceId}) {
return buildJellyfinTrickplayTileUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
deviceId: connection.deviceId,
itemId: itemId,
width: width,
sheetIndex: sheetIndex,
mediaSourceId: mediaSourceId,
);
}
/// Negotiate playback: returns the parsed `MediaSources[]` array and the
/// server's recommended `PlaySessionId`. Caller decides which media source
/// to use and feeds the returned `TranscodingUrl` into the player.
///
/// [maxStreamingBitrate] is forwarded as both the top-level field and inside
/// the `DeviceProfile` so the server caps direct-stream and transcode bitrate
/// against the same ceiling. [mediaSourceId] pins the negotiation to a
/// specific version when the item has multiple sources. [audioStreamIndex]
/// / [subtitleStreamIndex] tell the server which streams to pick for the
/// transcode profile (Jellyfin's negotiation factors them in when picking
/// codec compatibility).
Future<Map<String, dynamic>?> getPlaybackInfo(
String itemId, {
int maxStreamingBitrate = 100000000,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
try {
final query = <String, String>{
'userId': connection.userId,
'MaxStreamingBitrate': maxStreamingBitrate.toString(),
'MediaSourceId': ?mediaSourceId,
'AudioStreamIndex': ?audioStreamIndex?.toString(),
'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(),
};
final response = await _http.post(
'/Items/${_segment(itemId)}/PlaybackInfo',
queryParameters: query,
body: {
'UserId': connection.userId,
'MaxStreamingBitrate': maxStreamingBitrate,
'DeviceProfile': <String, Object?>{
'Name': 'Plezy',
'MaxStreamingBitrate': maxStreamingBitrate,
'CodecProfiles': const <Map<String, Object?>>[],
// Comma-separated codec lists are order-sensitive — first entry
// wins when the server picks an output codec. HEVC is listed
// ahead of H.264 so a server that has "Allow encoding in HEVC
// format" enabled will actually emit HEVC instead of falling
// back to H.264.
'TranscodingProfiles': const <Map<String, Object?>>[
{
'Type': 'Video',
'Container': 'ts',
'Protocol': 'hls',
'VideoCodec': 'hevc,h264',
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus',
},
],
// Declaring HEVC in DirectPlayProfile.VideoCodec stops the server
// from forcing a transcode for HEVC sources whose container we
// already accept — mpv decodes HEVC natively on every platform
// we ship.
'DirectPlayProfiles': const <Map<String, Object?>>[
{
'Type': 'Video',
'Container': 'mp4,mkv,m4v,webm,mov,ts',
'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4',
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,vorbis,dts',
},
],
'SubtitleProfiles': const <Map<String, Object?>>[
{'Format': 'srt', 'Method': 'External'},
{'Format': 'ass', 'Method': 'External'},
{'Format': 'ssa', 'Method': 'External'},
{'Format': 'vtt', 'Method': 'External'},
{'Format': 'pgssub', 'Method': 'External'},
{'Format': 'dvdsub', 'Method': 'External'},
{'Format': 'dvbsub', 'Method': 'External'},
],
},
},
);
throwIfHttpError(response);
final data = response.data;
return data is Map<String, dynamic> ? data : null;
} catch (e, st) {
appLogger.w('JellyfinClient: getPlaybackInfo failed', error: e, stackTrace: st);
return null;
}
}
@override
Future<ExternalIds> fetchExternalIds(String itemId) async {
final item = await fetchItem(itemId);
final raw = item?.raw;
final providerIds = raw is Map<String, dynamic> ? raw['ProviderIds'] : null;
if (providerIds is Map<String, dynamic>) {
return ExternalIds.fromJellyfinProviderIds(providerIds);
}
return const ExternalIds();
}
/// Jellyfin embeds the access token in the URL query string (`api_key=...`)
/// rather than relying on headers, so the player needs no extra headers
/// for direct streams.
@override
Map<String, String> get streamHeaders => const {};
/// Tell the server the user has started playing [itemId]. Body shape
/// mirrors the Jellyfin SDK's [PlaybackStartInfo] — Findroid sends the
/// same fields, and Jellyfin's session tracker drops events that omit
/// `PlayMethod` because it has no way to associate progress with an
/// active session row.
///
/// [duration] is accepted for interface symmetry with Plex but ignored —
/// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes
/// are still sent so the active session reflects the chosen tracks.
@override
Future<void> reportPlaybackStarted({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? playMethod,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
final response = await _http.post(
'/Sessions/Playing',
body: {
'ItemId': itemId,
'MediaSourceId': ?mediaSourceId,
'AudioStreamIndex': ?audioStreamIndex,
'SubtitleStreamIndex': ?subtitleStreamIndex,
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
'CanSeek': true,
'IsPaused': false,
'IsMuted': false,
'PlayMethod': playMethod ?? 'DirectPlay',
'RepeatMode': 'RepeatNone',
'PlaybackOrder': 'Default',
'PlaySessionId': ?playSessionId,
},
);
throwIfHttpError(response);
}
/// Periodic progress ping (510s cadence is typical). Server uses this to
/// drive the resume position, detect idle sessions, and save remembered
/// audio/subtitle stream indexes when enabled in Jellyfin user settings.
@override
Future<void> reportPlaybackProgress({
required String itemId,
required Duration position,
required Duration duration,
bool isPaused = false,
String? playSessionId,
String? playMethod,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
final response = await _http.post(
'/Sessions/Playing/Progress',
body: {
'ItemId': itemId,
'MediaSourceId': ?mediaSourceId,
'AudioStreamIndex': ?audioStreamIndex,
'SubtitleStreamIndex': ?subtitleStreamIndex,
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
'CanSeek': true,
'IsPaused': isPaused,
'IsMuted': false,
'PlayMethod': playMethod ?? 'DirectPlay',
'RepeatMode': 'RepeatNone',
'PlaybackOrder': 'Default',
'PlaySessionId': ?playSessionId,
},
);
throwIfHttpError(response);
}
/// End-of-playback signal. Final position becomes the resume bookmark.
/// [duration] is accepted for interface symmetry with Plex but ignored.
@override
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? mediaSourceId,
}) async {
final response = await _http.post(
'/Sessions/Playing/Stopped',
body: {
'ItemId': itemId,
'MediaSourceId': ?mediaSourceId,
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
'Failed': false,
'PlaySessionId': ?playSessionId,
},
);
throwIfHttpError(response);
}
}
@@ -0,0 +1,186 @@
part of '../../jellyfin_client.dart';
mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
MediaServerHttpClient get _http;
String? _absolutizeImagePath(String? path);
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
@override
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'IncludeItemTypes': 'Playlist',
'Recursive': 'true',
'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags',
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
final requestedType = playlistType.toLowerCase();
return _itemsArray(response.data).map(_playlistFromJson).where((playlist) {
if (requestedType.isNotEmpty && playlist.playlistType.toLowerCase() != requestedType) return false;
if (smart != null && playlist.smart != smart) return false;
return true;
}).toList();
}
@override
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
final item = await fetchItem(id);
if (item == null) return null;
return MediaPlaylist(
id: item.id,
backend: MediaBackend.jellyfin,
title: item.title ?? 'Playlist',
summary: item.summary,
smart: false,
playlistType: _playlistMediaType(item),
durationMs: item.durationMs,
leafCount: item.leafCount,
thumbPath: item.thumbPath,
addedAt: item.addedAt,
updatedAt: item.updatedAt,
serverId: serverId,
serverName: serverName,
);
}
@override
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
final response = await _http.get(
'/Playlists/${_segment(id)}/Items',
queryParameters: {
'userId': connection.userId,
'StartIndex': offset.toString(),
'Limit': limit.toString(),
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
);
throwIfHttpError(response);
return _mapItems(_itemsArray(response.data));
}
@override
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
final response = await _http.post(
'/Playlists',
queryParameters: {
'Name': title,
'Ids': items.map((i) => i.id).join(','),
'UserId': connection.userId,
'MediaType': 'Video',
},
);
throwIfHttpError(response);
final data = response.data;
final newId = data is Map<String, dynamic> ? data['Id'] as String? : null;
if (newId == null || newId.isEmpty) return null;
return fetchPlaylistMetadata(newId);
}
@override
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final response = await _http.post(
'/Playlists/${_segment(playlistId)}/Items',
queryParameters: {'Ids': items.map((i) => i.id).join(','), 'UserId': connection.userId},
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> deletePlaylist(MediaPlaylist playlist) async {
// Jellyfin treats playlists as items — same delete endpoint.
final response = await _http.delete('/Items/${_segment(playlist.id)}');
throwIfHttpError(response);
return true;
}
/// Jellyfin's move endpoint takes an absolute index, so [afterItem] is
/// ignored — its sibling Plex impl needs it for `?after=`. The "wrong
/// backend" / "missing playlistItemId" branches still return `false`
/// (business not-applicable, not a network error) so callers can revert
/// optimistic UI changes; an HTTP error throws like the rest of the
/// write surface.
@override
Future<bool> movePlaylistItem({
required String playlistId,
required MediaItem item,
required int newIndex,
required MediaItem? afterItem,
}) async {
if (item is! JellyfinMediaItem) {
appLogger.e('movePlaylistItem: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})');
return false;
}
if (item.playlistItemId == null) {
appLogger.e('movePlaylistItem: item ${item.id} ("${item.title}") has no playlistItemId');
return false;
}
final response = await _http.post(
'/Playlists/${_segment(playlistId)}/Items/${_segment(item.playlistItemId!)}/Move/$newIndex',
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) async {
if (item is! JellyfinMediaItem) {
appLogger.e('removeFromPlaylist: expected JellyfinMediaItem, got ${item.runtimeType} (id=${item.id})');
return false;
}
if (item.playlistItemId == null) {
appLogger.e('removeFromPlaylist: item ${item.id} ("${item.title}") has no playlistItemId');
return false;
}
final response = await _http.delete(
'/Playlists/${_segment(playlistId)}/Items',
queryParameters: {'entryIds': item.playlistItemId},
);
throwIfHttpError(response);
return true;
}
MediaPlaylist _playlistFromJson(Map<String, dynamic> json) {
final id = json['Id'] as String? ?? '';
return MediaPlaylist(
id: id,
backend: MediaBackend.jellyfin,
title: json['Name'] as String? ?? 'Playlist',
summary: json['Overview'] as String?,
smart: false,
playlistType: (json['MediaType'] as String?)?.toLowerCase() ?? 'video',
leafCount: json['ChildCount'] as int?,
addedAt: _epochSecondsFromJson(json['DateCreated'] as String?),
updatedAt: _epochSecondsFromJson(json['DateLastSaved'] as String?),
thumbPath: _absolutizeImagePath(_imageTagPath(id, json['ImageTags'])),
serverId: serverId,
serverName: serverName,
);
}
String _playlistMediaType(MediaItem item) {
if (item.kind == MediaKind.track || item.kind == MediaKind.album) return 'audio';
if (item.kind == MediaKind.photo) return 'photo';
return 'video';
}
int? _epochSecondsFromJson(String? iso) {
if (iso == null || iso.isEmpty) return null;
final dt = DateTime.tryParse(iso);
return dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000;
}
String? _imageTagPath(String id, Object? tags) {
if (tags is! Map<String, dynamic>) return null;
final tag = tags['Primary'];
if (tag is! String) return null;
return '/Items/${_segment(id)}/Images/Primary?tag=${Uri.encodeComponent(tag)}';
}
}
@@ -0,0 +1,45 @@
part of '../../jellyfin_client.dart';
mixin _JellyfinWatchStateMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
MediaServerHttpClient get _http;
@override
Future<void> markWatched(MediaItem item) async {
final response = await _http.post(
'/UserPlayedItems/${_segment(item.id)}',
queryParameters: {'userId': connection.userId},
);
throwIfHttpError(response);
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true, cacheServerId: cacheServerId);
}
@override
Future<void> markUnwatched(MediaItem item) async {
final response = await _http.delete(
'/UserPlayedItems/${_segment(item.id)}',
queryParameters: {'userId': connection.userId},
);
throwIfHttpError(response);
WatchStateNotifier().notifyWatched(item: item, isNowWatched: false, cacheServerId: cacheServerId);
}
@override
Future<void> removeFromContinueWatching(MediaItem item) async {
throw UnsupportedError('Jellyfin does not support removing items from Continue Watching.');
}
@override
Future<void> rate(MediaItem item, double rating) async {
// Lossy mapping — Jellyfin only stores a binary like/dislike. Treat
// a negative input as "clear the rating" (DELETE), >= 6/10 as a like
// (POST Likes=true), and the rest as a dislike (POST Likes=false).
final response = rating < 0
? await _http.delete('/UserItems/${_segment(item.id)}/Rating', queryParameters: {'userId': connection.userId})
: await _http.post(
'/UserItems/${_segment(item.id)}/Rating',
queryParameters: {'userId': connection.userId, 'Likes': (rating >= 6.0).toString()},
);
throwIfHttpError(response);
}
}