refactor(media): split media and jellyfin client
This commit is contained in:
+4
-1487
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 [],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
-2025
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);
|
||||
}
|
||||
}
|
||||
+148
-3
@@ -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 (5–10s 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user