feat(music): add music data layer for plex and jellyfin
Backend-neutral fetchArtistAlbums/fetchAlbumTracks/fetchInstantMix/ fetchLyrics, lyrics model + LRC parser, audio quality presets, music transcode (plex /music universal, jellyfin audio PlaybackInfo profile), station/InstantMix radio, music search, playlist type derivation, and audio download resolution.
This commit is contained in:
@@ -700,6 +700,9 @@
|
||||
"nextUpIn": "Next Up in ${library}",
|
||||
"recentlyAdded": "Recently Added",
|
||||
"recentlyAddedIn": "Recently Added in ${library}",
|
||||
"latestAlbumsIn": "Latest Albums in ${library}",
|
||||
"recentlyPlayedIn": "Recently Played in ${library}",
|
||||
"mostPlayedIn": "Most Played in ${library}",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Overview",
|
||||
"cast": "Cast",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 16
|
||||
/// Strings: 20863 (1303 per locale)
|
||||
/// Strings: 20866 (1304 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -2155,6 +2155,15 @@ class TranslationsDiscoverEn {
|
||||
/// en: 'Recently Added in ${library}'
|
||||
String recentlyAddedIn({required Object library}) => 'Recently Added in ${library}';
|
||||
|
||||
/// en: 'Latest Albums in ${library}'
|
||||
String latestAlbumsIn({required Object library}) => 'Latest Albums in ${library}';
|
||||
|
||||
/// en: 'Recently Played in ${library}'
|
||||
String recentlyPlayedIn({required Object library}) => 'Recently Played in ${library}';
|
||||
|
||||
/// en: 'Most Played in ${library}'
|
||||
String mostPlayedIn({required Object library}) => 'Most Played in ${library}';
|
||||
|
||||
/// en: 'S${season}E${episode}'
|
||||
String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}';
|
||||
|
||||
@@ -5265,6 +5274,9 @@ extension on Translations {
|
||||
'discover.nextUpIn' => ({required Object library}) => 'Next Up in ${library}',
|
||||
'discover.recentlyAdded' => 'Recently Added',
|
||||
'discover.recentlyAddedIn' => ({required Object library}) => 'Recently Added in ${library}',
|
||||
'discover.latestAlbumsIn' => ({required Object library}) => 'Latest Albums in ${library}',
|
||||
'discover.recentlyPlayedIn' => ({required Object library}) => 'Recently Played in ${library}',
|
||||
'discover.mostPlayedIn' => ({required Object library}) => 'Most Played in ${library}',
|
||||
'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}',
|
||||
'discover.overview' => 'Overview',
|
||||
'discover.cast' => 'Cast',
|
||||
@@ -5632,11 +5644,11 @@ extension on Translations {
|
||||
'shaders.title' => 'Shaders',
|
||||
'shaders.noShaderDescription' => 'No video enhancement',
|
||||
'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'shaders.artcnnVariantNeutral' => 'Neutral',
|
||||
'shaders.artcnnVariantDenoise' => 'Denoise',
|
||||
'shaders.artcnnVariantDenoiseSharpen' => 'Denoise + Sharpen',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'shaders.qualityFast' => 'Fast',
|
||||
'shaders.qualityHQ' => 'High Quality',
|
||||
'shaders.mode' => 'Mode',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/// One lyric line. [startMs] is the offset from track start, `null` when the
|
||||
/// source is unsynced plain text.
|
||||
class LyricLine {
|
||||
final String text;
|
||||
final int? startMs;
|
||||
|
||||
const LyricLine({required this.text, this.startMs});
|
||||
}
|
||||
|
||||
/// Track lyrics as returned by [MediaServerClient.fetchLyrics].
|
||||
///
|
||||
/// [synced] is true when (enough) lines carry [LyricLine.startMs] for the
|
||||
/// player to highlight/scroll along with playback. Jellyfin's `LyricDto`
|
||||
/// omits its `IsSynced` flag on some server versions, so implementations
|
||||
/// infer synced-ness from the presence of per-line offsets.
|
||||
class Lyrics {
|
||||
final bool synced;
|
||||
final List<LyricLine> lines;
|
||||
|
||||
const Lyrics({required this.synced, required this.lines});
|
||||
|
||||
bool get isEmpty => lines.isEmpty;
|
||||
}
|
||||
@@ -464,6 +464,34 @@ sealed class MediaItem with _$MediaItem {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Track number within its disc, for [MediaKind.track] items.
|
||||
int? get trackNumber => kind == MediaKind.track ? index : null;
|
||||
|
||||
/// Disc number for [MediaKind.track] items (Plex `parentIndex`, Jellyfin
|
||||
/// `ParentIndexNumber`). Null/1 on single-disc albums.
|
||||
int? get discNumber => kind == MediaKind.track ? parentIndex : null;
|
||||
|
||||
/// Album title for music items: a track's parent, an album's own title.
|
||||
String? get albumTitle => switch (kind) {
|
||||
MediaKind.track => parentTitle,
|
||||
MediaKind.album => title,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Album-artist name for music items: a track's grandparent, an album's
|
||||
/// parent.
|
||||
String? get albumArtistTitle => switch (kind) {
|
||||
MediaKind.track => grandparentTitle,
|
||||
MediaKind.album => parentTitle,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Performing artist of a track. Falls back to [albumArtistTitle] — both
|
||||
/// backends only populate a separate value when it differs (Plex stores a
|
||||
/// compilation track's own artist in `originalTitle`; the Jellyfin mapper
|
||||
/// mirrors that convention from `Artists`).
|
||||
String? get trackArtistTitle => kind == MediaKind.track ? (originalTitle ?? albumArtistTitle) : null;
|
||||
|
||||
/// Plex-only edition label. Jellyfin returns null.
|
||||
String? get editionTitle => null;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'library_filter_result.dart';
|
||||
import 'library_first_character.dart';
|
||||
import 'library_query.dart';
|
||||
import 'live_tv_support.dart';
|
||||
import 'lyrics.dart';
|
||||
import 'media_backend.dart';
|
||||
import 'media_file_info.dart';
|
||||
import 'media_hub.dart';
|
||||
@@ -235,6 +236,35 @@ abstract class MediaServerClient {
|
||||
/// series" via the null vs `[]` distinction.
|
||||
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId);
|
||||
|
||||
/// Albums credited to the artist [artistId], newest first. Not the same as
|
||||
/// [fetchChildren]: Plex artists *are* folder-parents of their albums
|
||||
/// (`/library/metadata/{id}/children`), but Jellyfin albums link to artists
|
||||
/// only via tags, so it queries
|
||||
/// `/Items?AlbumArtistIds={id}&IncludeItemTypes=MusicAlbum`.
|
||||
Future<List<MediaItem>> fetchArtistAlbums(String artistId);
|
||||
|
||||
/// Tracks of album [albumId] in disc/track order. Plex:
|
||||
/// `/library/metadata/{id}/children`; Jellyfin:
|
||||
/// `/Items?AlbumIds={id}&IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber`
|
||||
/// (AlbumIds rather than ParentId so tag-based albums whose files share one
|
||||
/// physical folder still resolve).
|
||||
Future<List<MediaItem>> fetchAlbumTracks(String albumId);
|
||||
|
||||
/// Server-built "instant mix" / radio track list seeded from [itemId]
|
||||
/// (track, album, artist, or playlist). Jellyfin:
|
||||
/// `/Items/{id}/InstantMix`; Plex: a station play queue
|
||||
/// (`POST /playQueues?type=audio&uri=...station...`), consumed here as a
|
||||
/// plain track list — music playback is queue-managed client-side on both
|
||||
/// backends. Gated by [ServerCapabilities.instantMix].
|
||||
Future<List<MediaItem>> fetchInstantMix(String itemId, {int limit = 100});
|
||||
|
||||
/// Lyrics for [track], or `null` when the server has none. Jellyfin:
|
||||
/// `/Audio/{id}/Lyrics` (per-line tick offsets when synced); Plex: a
|
||||
/// sidecar-lyrics track stream (`streamType 4`) fetched from
|
||||
/// `/library/streams/{id}` and parsed from LRC. Synced-ness is per
|
||||
/// [Lyrics.synced]; gated by [ServerCapabilities.lyrics].
|
||||
Future<Lyrics?> fetchLyrics(MediaItem track);
|
||||
|
||||
/// Free-text search across the user's libraries.
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100});
|
||||
|
||||
|
||||
@@ -109,6 +109,22 @@ class ServerCapabilities {
|
||||
/// `/Items?ParentId=...&Recursive=false` queries.
|
||||
final bool folderGrouping;
|
||||
|
||||
/// Server can supply track lyrics. Jellyfin exposes `/Audio/{id}/Lyrics`;
|
||||
/// Plex surfaces sidecar `.lrc`/`.txt` files as track streams
|
||||
/// (`streamType 4`) fetched via `/library/streams/{id}`. Gates the lyrics
|
||||
/// affordance in the music player; per-track absence is the runtime gate.
|
||||
final bool lyrics;
|
||||
|
||||
/// Server can build an "instant mix" / radio track list from a seed item.
|
||||
/// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues
|
||||
/// (`POST /playQueues?type=audio&uri=...station...`).
|
||||
final bool instantMix;
|
||||
|
||||
/// Server can transcode audio to a capped bitrate. Plex:
|
||||
/// `/music/:/transcode/universal`; Jellyfin: `PlaybackInfo` with an audio
|
||||
/// `TranscodingProfile`. Gates the music quality picker (vs original-only).
|
||||
final bool audioTranscoding;
|
||||
|
||||
const ServerCapabilities({
|
||||
this.serverSidePlayQueue = false,
|
||||
this.serverSidePlaylists = false,
|
||||
@@ -130,6 +146,9 @@ class ServerCapabilities {
|
||||
this.alphaBar = AlphaBarMode.none,
|
||||
this.scrubThumbnails = false,
|
||||
this.folderGrouping = false,
|
||||
this.lyrics = false,
|
||||
this.instantMix = false,
|
||||
this.audioTranscoding = false,
|
||||
});
|
||||
|
||||
/// Defaults for a fully-featured Plex server.
|
||||
@@ -154,6 +173,9 @@ class ServerCapabilities {
|
||||
alphaBar: AlphaBarMode.scrollSnap,
|
||||
scrubThumbnails: true,
|
||||
folderGrouping: true,
|
||||
lyrics: true,
|
||||
instantMix: true,
|
||||
audioTranscoding: true,
|
||||
);
|
||||
|
||||
/// Defaults for a Jellyfin server.
|
||||
@@ -185,6 +207,9 @@ class ServerCapabilities {
|
||||
alphaBar: AlphaBarMode.nameStartsWithFilter,
|
||||
scrubThumbnails: true,
|
||||
folderGrouping: true,
|
||||
lyrics: true,
|
||||
instantMix: true,
|
||||
audioTranscoding: true,
|
||||
);
|
||||
|
||||
ServerCapabilities copyWith({
|
||||
@@ -208,6 +233,9 @@ class ServerCapabilities {
|
||||
AlphaBarMode? alphaBar,
|
||||
bool? scrubThumbnails,
|
||||
bool? folderGrouping,
|
||||
bool? lyrics,
|
||||
bool? instantMix,
|
||||
bool? audioTranscoding,
|
||||
}) {
|
||||
return ServerCapabilities(
|
||||
serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue,
|
||||
@@ -230,6 +258,9 @@ class ServerCapabilities {
|
||||
alphaBar: alphaBar ?? this.alphaBar,
|
||||
scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails,
|
||||
folderGrouping: folderGrouping ?? this.folderGrouping,
|
||||
lyrics: lyrics ?? this.lyrics,
|
||||
instantMix: instantMix ?? this.instantMix,
|
||||
audioTranscoding: audioTranscoding ?? this.audioTranscoding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/// Music streaming quality presets.
|
||||
///
|
||||
/// When a non-[original] preset is selected, track playback asks the active
|
||||
/// backend for a bitrate-capped audio transcode (Plex
|
||||
/// `/music/:/transcode/universal` with `musicBitrate`, Jellyfin `PlaybackInfo`
|
||||
/// with a capped `MaxStreamingBitrate`). [original] bypasses transcoding and
|
||||
/// direct-plays the source file. Deliberately separate from
|
||||
/// [TranscodeQualityPreset] — its members are video-shaped
|
||||
/// (resolution/videoQuality).
|
||||
enum AudioQualityPreset {
|
||||
original(null),
|
||||
high(320),
|
||||
medium(192),
|
||||
low(128);
|
||||
|
||||
const AudioQualityPreset(this.bitrateKbps);
|
||||
|
||||
final int? bitrateKbps;
|
||||
|
||||
bool get isOriginal => this == AudioQualityPreset.original;
|
||||
|
||||
String get storageKey => name;
|
||||
|
||||
static AudioQualityPreset fromStorage(String? stored) {
|
||||
if (stored == null) return AudioQualityPreset.original;
|
||||
for (final v in AudioQualityPreset.values) {
|
||||
if (v.name == stored) return v;
|
||||
}
|
||||
return AudioQualityPreset.original;
|
||||
}
|
||||
|
||||
/// Order shared by every picker surface: [original] first, then capped
|
||||
/// presets highest-bitrate first.
|
||||
static final List<AudioQualityPreset> displayOrder = List.unmodifiable(values);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import 'file_info_parser.dart';
|
||||
import 'library_query_translator.dart';
|
||||
import '../media/media_filter.dart';
|
||||
import '../media/live_tv_support.dart';
|
||||
import '../media/lyrics.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_file_info.dart';
|
||||
import '../media/media_hub.dart';
|
||||
@@ -26,6 +27,7 @@ import '../media/ids.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../media/playback_report_metadata.dart';
|
||||
import '../media/server_capabilities.dart';
|
||||
import '../models/audio_quality_preset.dart';
|
||||
import '../models/jellyfin/jellyfin_user_profile.dart';
|
||||
import '../models/livetv_capture_buffer.dart';
|
||||
import '../models/livetv_channel.dart';
|
||||
@@ -69,6 +71,7 @@ import 'scrub_preview_source.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
|
||||
part 'jellyfin_client/parts/browse.dart';
|
||||
part 'jellyfin_client/parts/music.dart';
|
||||
part 'jellyfin_client/parts/playback.dart';
|
||||
part 'jellyfin_client/parts/watch_state.dart';
|
||||
part 'jellyfin_client/parts/playlists.dart';
|
||||
@@ -88,6 +91,7 @@ class JellyfinClient
|
||||
with
|
||||
MediaServerCacheMixin,
|
||||
_JellyfinBrowseMethods,
|
||||
_JellyfinMusicMethods,
|
||||
_JellyfinPlaybackMethods,
|
||||
_JellyfinWatchStateMethods,
|
||||
_JellyfinPlaylistMethods,
|
||||
|
||||
@@ -90,7 +90,7 @@ const _continueWatchingSeriesLookback = 200;
|
||||
|
||||
const _childrenPageSize = 500;
|
||||
const _pagedListPageSize = 200;
|
||||
const _playableDescendantTypes = 'Movie,Episode';
|
||||
const _playableDescendantTypes = 'Movie,Episode,Audio';
|
||||
const _playableFolderDescendantTypes = 'Movie,Episode,Video,MusicVideo';
|
||||
const _episodeOrderQueryParameters = {
|
||||
'SortBy': 'ParentIndexNumber,IndexNumber,SortName',
|
||||
@@ -179,12 +179,35 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
);
|
||||
final params = translator.toQueryParameters(query);
|
||||
|
||||
final response = await _http.get('/Items', queryParameters: params, abort: abort);
|
||||
// Artist browsing routes to `/Artists/AlbumArtists` instead of
|
||||
// `/Items?IncludeItemTypes=MusicArtist`: the /Items query only returns
|
||||
// folder-derived artists under their folder names, missing tag-only
|
||||
// per-track artists entirely (and folder names can differ from the tag
|
||||
// names shown everywhere else). AlbumArtists matches Plex's "album
|
||||
// artists" library semantic. The branch lives here rather than in the
|
||||
// translator because the translator's contract is query *parameters*
|
||||
// only — the endpoint choice is client routing, like the seasons vs
|
||||
// generic-children split in [fetchChildrenPage]. The artists endpoint
|
||||
// accepts the same paging/sort/filter/prefix params /Items does
|
||||
// (ParentId, StartIndex, Limit, SortBy/SortOrder, NameStartsWith/
|
||||
// NameLessThan, Filters, Fields) and ignores the /Items-only keys.
|
||||
final isArtistQuery = query.kind == MediaKind.artist;
|
||||
final endpoint = isArtistQuery ? '/Artists/AlbumArtists' : '/Items';
|
||||
if (isArtistQuery) {
|
||||
params.remove('IncludeItemTypes');
|
||||
params.remove('Recursive');
|
||||
}
|
||||
|
||||
final response = await _http.get(endpoint, queryParameters: params, abort: abort);
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
final items = _itemsArray(data);
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
final total = rawTotal is int
|
||||
// /Artists/AlbumArtists reports TotalRecordCount=0 when NameStartsWith /
|
||||
// NameLessThan are set (server-side counting quirk, observed on 10.11);
|
||||
// treat that as "unknown" so the alpha-prefix filter can still page.
|
||||
final totalUnreliable = isArtistQuery && rawTotal == 0 && items.isNotEmpty;
|
||||
final total = rawTotal is int && !totalUnreliable
|
||||
? rawTotal
|
||||
: _fallbackPageTotal(offset: query.offset, itemCount: items.length, requestedSize: query.limit);
|
||||
return LibraryPage<MediaItem>(items: _mapItems(items), totalCount: total, offset: query.offset);
|
||||
@@ -844,10 +867,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
return _mapItems(allRaw);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// All directly-playable descendants of [parentId] (Movies + Episodes +
|
||||
/// Audio tracks), recursively expanded. Used by the playback launcher so a
|
||||
/// collection containing a Series plays its episodes instead of the
|
||||
/// unplayable Series entry, a playlist mixing both comes through the same
|
||||
/// path, and an album/artist/audio-playlist expands to its tracks.
|
||||
/// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems]
|
||||
/// since those preserve the container shape (Series rows, PlaylistItemId).
|
||||
///
|
||||
@@ -978,20 +1002,28 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
// Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=`
|
||||
// only matches folder-derived MusicArtist rows (under folder names), so
|
||||
// tag-only artists would never appear in search. The artists leg is
|
||||
// best-effort — a music-endpoint hiccup shouldn't sink video search.
|
||||
final results = await Future.wait([
|
||||
_fetchItemsArray('/Items', {
|
||||
'userId': connection.userId,
|
||||
'SearchTerm': query,
|
||||
'Recursive': 'true',
|
||||
'Limit': limit.toString(),
|
||||
'IncludeItemTypes': 'Movie,Series,Episode',
|
||||
'IncludeItemTypes': 'Movie,Series,Episode,MusicAlbum,Audio',
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
}),
|
||||
_safeFetchItemsArray('/Artists', {
|
||||
'userId': connection.userId,
|
||||
'searchTerm': query,
|
||||
'Limit': limit.toString(),
|
||||
...jellyfinImageQueryParameters,
|
||||
}),
|
||||
]);
|
||||
return _mapItems([...results.first, ...results[1]]);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1192,6 +1224,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
...jellyfinImageQueryParameters,
|
||||
}, retry: _libraryHubRetry);
|
||||
|
||||
// Music libraries get their own hub set (Latest Albums / Recently Played /
|
||||
// Most Played) — Resume and NextUp are video concepts and Jellyfin's
|
||||
// Resume endpoint is queried with MediaTypes=Video anyway. The branch
|
||||
// ignores [includePlaybackHubs]: the played rows never duplicate the
|
||||
// app-level Continue Watching shelf that flag exists to dedupe.
|
||||
if (libraryKind == MediaKind.artist) {
|
||||
return _fetchMusicLibraryHubs(libraryId, libraryName: libraryName, limit: limit, latestFuture: latestFuture);
|
||||
}
|
||||
|
||||
if (!includePlaybackHubs) {
|
||||
final latest = await latestFuture;
|
||||
return [
|
||||
@@ -1268,11 +1309,76 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
].where((h) => h.items.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Music-library hub set, mirroring the Jellyfin web client's music
|
||||
/// "Suggestions" tab. `Latest Albums` reuses the `.recent` identifier —
|
||||
/// `/Users/{userId}/Items/Latest` natively groups a music library's new
|
||||
/// items into albums, so the existing `recent` paging path in
|
||||
/// [fetchMoreHubItemsPage] is already the correct expansion. The played
|
||||
/// rows filter `IsPlayed` so unplayed tracks (PlayCount 0) never pad them.
|
||||
Future<List<MediaHub>> _fetchMusicLibraryHubs(
|
||||
String libraryId, {
|
||||
required String libraryName,
|
||||
required int limit,
|
||||
required Future<List<Map<String, dynamic>>> latestFuture,
|
||||
}) async {
|
||||
final playedParams = <String, String>{
|
||||
'userId': connection.userId,
|
||||
'ParentId': libraryId,
|
||||
'IncludeItemTypes': 'Audio',
|
||||
'Recursive': 'true',
|
||||
'Filters': 'IsPlayed',
|
||||
'SortOrder': 'Descending',
|
||||
'Limit': limit.toString(),
|
||||
'Fields': _browseFields,
|
||||
'EnableTotalRecordCount': 'false',
|
||||
...jellyfinImageQueryParameters,
|
||||
};
|
||||
final results = await Future.wait([
|
||||
latestFuture,
|
||||
_safeFetchItemsArray('/Items', {...playedParams, 'SortBy': 'DatePlayed'}, retry: _libraryHubRetry),
|
||||
_safeFetchItemsArray('/Items', {...playedParams, 'SortBy': 'PlayCount'}, retry: _libraryHubRetry),
|
||||
]);
|
||||
|
||||
return [
|
||||
JellyfinMappers.syntheticHub(
|
||||
mapItem: _mapItem,
|
||||
identifier: 'library.$libraryId.recent',
|
||||
title: t.discover.latestAlbumsIn(library: libraryName),
|
||||
type: 'album',
|
||||
items: results.first,
|
||||
previewLimit: limit,
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
),
|
||||
JellyfinMappers.syntheticHub(
|
||||
mapItem: _mapItem,
|
||||
identifier: 'library.$libraryId.recentlyplayed',
|
||||
title: t.discover.recentlyPlayedIn(library: libraryName),
|
||||
type: 'track',
|
||||
items: results[1],
|
||||
previewLimit: limit,
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
),
|
||||
JellyfinMappers.syntheticHub(
|
||||
mapItem: _mapItem,
|
||||
identifier: 'library.$libraryId.mostplayed',
|
||||
title: t.discover.mostPlayedIn(library: libraryName),
|
||||
type: 'track',
|
||||
items: results[2],
|
||||
previewLimit: limit,
|
||||
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.
|
||||
/// `*.nextup` → NextUp, `*.recentlyplayed` / `*.mostplayed` → the music
|
||||
/// played-track queries. Unknown ids return an empty list.
|
||||
@override
|
||||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) async {
|
||||
try {
|
||||
@@ -1356,6 +1462,28 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
requestedSize: pageSize,
|
||||
abort: abort,
|
||||
);
|
||||
case 'recentlyplayed':
|
||||
case 'mostplayed':
|
||||
return _safeFetchMediaPage(
|
||||
'/Items',
|
||||
{
|
||||
'userId': connection.userId,
|
||||
'ParentId': ?parentId,
|
||||
'IncludeItemTypes': 'Audio',
|
||||
'Recursive': 'true',
|
||||
'Filters': 'IsPlayed',
|
||||
'SortBy': tail == 'mostplayed' ? 'PlayCount' : 'DatePlayed',
|
||||
'SortOrder': 'Descending',
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': _browseFields,
|
||||
'EnableTotalRecordCount': 'true',
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
offset: offset,
|
||||
requestedSize: pageSize,
|
||||
abort: abort,
|
||||
);
|
||||
default:
|
||||
return LibraryPage<MediaItem>(items: const [], totalCount: 0, offset: offset);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
String? liveStreamId,
|
||||
int? audioStreamIndex,
|
||||
});
|
||||
String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId});
|
||||
Future<Map<String, dynamic>?> getPlaybackInfo(
|
||||
String itemId, {
|
||||
int? maxStreamingBitrate = 100_000_000,
|
||||
@@ -30,6 +31,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
bool? enableTranscoding,
|
||||
bool? allowVideoStreamCopy,
|
||||
bool? allowAudioStreamCopy,
|
||||
bool audioProfile,
|
||||
});
|
||||
String _withApiKey(String urlOrPath);
|
||||
|
||||
@@ -55,19 +57,36 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
|
||||
@override
|
||||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
|
||||
// Tracks stream from /Audio/{id}/stream; the URL contract (Static=true,
|
||||
// api_key in the query string) is otherwise identical to the video one.
|
||||
final isTrack = item.kind == MediaKind.track;
|
||||
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex, sourceId: mediaSourceId);
|
||||
if (bundle == null) return buildDirectStreamUrl(item.id);
|
||||
return buildDirectStreamUrl(
|
||||
item.id,
|
||||
container: bundle.container,
|
||||
mediaSourceId: bundle.pinnedSourceIdForItem(item.id),
|
||||
);
|
||||
if (bundle == null) {
|
||||
return isTrack ? buildAudioDirectStreamUrl(item.id) : buildDirectStreamUrl(item.id);
|
||||
}
|
||||
final container = bundle.container;
|
||||
final pinnedSourceId = bundle.pinnedSourceIdForItem(item.id);
|
||||
return isTrack
|
||||
? buildAudioDirectStreamUrl(item.id, container: container, mediaSourceId: pinnedSourceId)
|
||||
: buildDirectStreamUrl(item.id, container: 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;
|
||||
|
||||
// Tracks download from the audio static-stream endpoint and have no
|
||||
// subtitle sidecars to enumerate.
|
||||
if (item.kind == MediaKind.track) {
|
||||
final audioUrl = buildAudioDirectStreamUrl(
|
||||
item.id,
|
||||
container: bundle?.container,
|
||||
mediaSourceId: bundle?.pinnedSourceIdForItem(item.id),
|
||||
);
|
||||
return DownloadResolution(videoUrl: audioUrl, mediaSourceId: selectedSourceId, externalSubtitles: const []);
|
||||
}
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
part of '../../jellyfin_client.dart';
|
||||
|
||||
/// Music browsing + playback-adjacent reads: artist discography, album track
|
||||
/// listings, instant mix, and lyrics. Endpoint conventions follow the
|
||||
/// Jellyfin web client's music surface (cross-checked against the Kotlin
|
||||
/// SDK), mirroring the style notes at the top of `browse.dart`.
|
||||
mixin _JellyfinMusicMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||
|
||||
/// Albums credited to [artistId], newest first. Queries `AlbumArtistIds`
|
||||
/// rather than `ParentId` because Jellyfin links albums to artists via
|
||||
/// tags — an artist's albums are usually not its folder children.
|
||||
@override
|
||||
Future<List<MediaItem>> fetchArtistAlbums(String artistId) async {
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'AlbumArtistIds': artistId,
|
||||
'IncludeItemTypes': 'MusicAlbum',
|
||||
'Recursive': 'true',
|
||||
'SortBy': 'PremiereDate,ProductionYear,SortName',
|
||||
'SortOrder': 'Descending',
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
}
|
||||
|
||||
/// Tracks of [albumId] in disc/track order. `AlbumIds` (not `ParentId`) so
|
||||
/// tag-based albums whose files share one physical folder still resolve;
|
||||
/// `ParentIndexNumber,IndexNumber` yields correct multi-disc ordering.
|
||||
@override
|
||||
Future<List<MediaItem>> fetchAlbumTracks(String albumId) async {
|
||||
final response = await _http.get(
|
||||
'/Items',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'AlbumIds': albumId,
|
||||
'IncludeItemTypes': 'Audio',
|
||||
'Recursive': 'true',
|
||||
'SortBy': 'ParentIndexNumber,IndexNumber,SortName',
|
||||
'SortOrder': 'Ascending',
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
}
|
||||
|
||||
/// Server-built radio seeded from a track/album/artist/playlist id.
|
||||
@override
|
||||
Future<List<MediaItem>> fetchInstantMix(String itemId, {int limit = 100}) async {
|
||||
final response = await _http.get(
|
||||
'/Items/${_segment(itemId)}/InstantMix',
|
||||
queryParameters: {
|
||||
'userId': connection.userId,
|
||||
'Limit': limit.toString(),
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _mapItems(_itemsArray(response.data));
|
||||
}
|
||||
|
||||
/// Lyrics for [track] from `/Audio/{id}/Lyrics`. Jellyfin's `LyricDto`
|
||||
/// carries per-line `Start` offsets in ticks when the source is an LRC /
|
||||
/// synced provider; `IsSynced` is absent on some server versions, so
|
||||
/// synced-ness is inferred from any line carrying a `Start`. 404 means
|
||||
/// the track has no lyrics → `null`.
|
||||
@override
|
||||
Future<Lyrics?> fetchLyrics(MediaItem track) async {
|
||||
try {
|
||||
final response = await _http.get('/Audio/${_segment(track.id)}/Lyrics');
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
if (data is! Map<String, dynamic>) return null;
|
||||
final rawLines = data['Lyrics'];
|
||||
if (rawLines is! List) return null;
|
||||
final lines = <LyricLine>[];
|
||||
var synced = false;
|
||||
for (final raw in rawLines) {
|
||||
if (raw is! Map<String, dynamic>) continue;
|
||||
final startMs = jellyfinTicksToMs(raw['Start']);
|
||||
if (startMs != null) synced = true;
|
||||
lines.add(LyricLine(text: raw['Text'] as String? ?? '', startMs: startMs));
|
||||
}
|
||||
if (lines.isEmpty) return null;
|
||||
return Lyrics(synced: synced, lines: lines);
|
||||
} on MediaServerHttpException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,11 +159,24 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
var isTranscoding = false;
|
||||
TranscodeFallbackReason? fallbackReason;
|
||||
|
||||
// Tracks negotiate with the audio device profile and ignore the
|
||||
// (video-shaped) [PlaybackInitializationOptions.qualityPreset]; capping
|
||||
// comes from [PlaybackInitializationOptions.audioQualityPreset] instead.
|
||||
// Original / null keeps the unlimited default so high-bitrate lossless
|
||||
// files direct-play uncapped.
|
||||
final isTrack = metadata.kind == MediaKind.track;
|
||||
final preset = options.qualityPreset;
|
||||
final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
|
||||
final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal;
|
||||
final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
|
||||
final int? maxStreamingBitrate = preset.isOriginal ? null : (preset.videoBitrateKbps ?? 100_000) * 1000;
|
||||
final int? maxStreamingBitrate = wantsOriginal
|
||||
? null
|
||||
: isTrack
|
||||
// Non-original audio presets always carry a bitrate by construction.
|
||||
? audioPreset.bitrateKbps! * 1000
|
||||
: (preset.videoBitrateKbps ?? 100_000) * 1000;
|
||||
final resumeOffsetMs = metadata.viewOffsetMs;
|
||||
final int? transcodeStartTimeTicks = !preset.isOriginal && resumeOffsetMs != null && resumeOffsetMs > 0
|
||||
final int? transcodeStartTimeTicks = !wantsOriginal && resumeOffsetMs != null && resumeOffsetMs > 0
|
||||
? msToJellyfinTicks(resumeOffsetMs)
|
||||
: null;
|
||||
final negotiation = await getPlaybackInfo(
|
||||
@@ -172,9 +185,10 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
mediaSourceId: bundle.selectedSourceId,
|
||||
startTimeTicks: transcodeStartTimeTicks,
|
||||
audioStreamIndex: requestedAudioStreamId,
|
||||
audioProfile: isTrack,
|
||||
);
|
||||
if (negotiation == null) {
|
||||
if (!preset.isOriginal) {
|
||||
if (!wantsOriginal) {
|
||||
fallbackReason = TranscodeFallbackReason.decisionFailed;
|
||||
}
|
||||
} else {
|
||||
@@ -200,7 +214,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
|
||||
final transcodingUrl = chosenSource['TranscodingUrl'];
|
||||
final directStreamUrl = chosenSource['DirectStreamUrl'];
|
||||
if (!preset.isOriginal && transcodingUrl is String && transcodingUrl.isNotEmpty) {
|
||||
if (!wantsOriginal && 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.
|
||||
@@ -214,25 +228,31 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
videoUrl = _withApiKey(directStreamUrl);
|
||||
playMethod = 'DirectStream';
|
||||
} else {
|
||||
if (!preset.isOriginal) {
|
||||
if (!wantsOriginal) {
|
||||
fallbackReason = TranscodeFallbackReason.directPlayOnly;
|
||||
}
|
||||
}
|
||||
} else if (!preset.isOriginal) {
|
||||
} else if (!wantsOriginal) {
|
||||
fallbackReason = TranscodeFallbackReason.directPlayOnly;
|
||||
}
|
||||
}
|
||||
|
||||
final effectiveAudioStreamId = _resolveJellyfinAudioStreamId(requestedAudioStreamId, mediaInfo);
|
||||
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
|
||||
final externalSubtitles = _buildExternalSubtitles(
|
||||
metadata.id,
|
||||
effectiveSourceId,
|
||||
mediaInfo,
|
||||
includeExternalDelivery: includeExternalSubtitleDelivery,
|
||||
);
|
||||
// Tracks have no subtitle streams to assemble (a `Lyric` stream may be
|
||||
// present, but lyrics flow through fetchLyrics, not the subtitle path).
|
||||
final externalSubtitles = isTrack
|
||||
? const <SubtitleTrack>[]
|
||||
: _buildExternalSubtitles(
|
||||
metadata.id,
|
||||
effectiveSourceId,
|
||||
mediaInfo,
|
||||
includeExternalDelivery: includeExternalSubtitleDelivery,
|
||||
);
|
||||
final pinnedSourceId = bundle.pinnedSourceIdForItem(metadata.id);
|
||||
videoUrl ??= buildDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId);
|
||||
videoUrl ??= isTrack
|
||||
? buildAudioDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId)
|
||||
: buildDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId);
|
||||
|
||||
return PlaybackInitializationResult(
|
||||
availableVersions: bundle.availableVersions,
|
||||
@@ -436,6 +456,21 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
);
|
||||
}
|
||||
|
||||
/// Audio sibling of [buildDirectStreamUrl]: `/Audio/{id}/stream` with the
|
||||
/// same `Static=true` + `api_key` + `DeviceId` self-authentication. Used
|
||||
/// for track direct-play fallback, downloads, and external players.
|
||||
String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) {
|
||||
return buildJellyfinDirectStreamUrl(
|
||||
baseUrl: connection.baseUrl,
|
||||
accessToken: connection.accessToken,
|
||||
deviceId: connection.deviceId,
|
||||
itemId: itemId,
|
||||
mediaSegment: 'Audio',
|
||||
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).
|
||||
@@ -469,6 +504,9 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
/// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams
|
||||
/// to pick for the transcode profile (Jellyfin's negotiation factors them in
|
||||
/// when picking codec compatibility).
|
||||
/// [audioProfile] extends the DeviceProfile with music direct-play and
|
||||
/// audio→mp3 transcode entries for track playback; the video profiles (and
|
||||
/// the request body when false) are untouched either way.
|
||||
Future<Map<String, dynamic>?> getPlaybackInfo(
|
||||
String itemId, {
|
||||
int? maxStreamingBitrate = 100_000_000,
|
||||
@@ -483,6 +521,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
bool? enableTranscoding,
|
||||
bool? allowVideoStreamCopy,
|
||||
bool? allowAudioStreamCopy,
|
||||
bool audioProfile = false,
|
||||
}) async {
|
||||
try {
|
||||
final query = <String, String>{
|
||||
@@ -526,26 +565,45 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
// 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?>>[
|
||||
{
|
||||
'TranscodingProfiles': <Map<String, Object?>>[
|
||||
const {
|
||||
'Type': 'Video',
|
||||
'Container': 'ts',
|
||||
'Protocol': 'hls',
|
||||
'VideoCodec': 'hevc,h264',
|
||||
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus',
|
||||
},
|
||||
// Track playback transcode target: stereo mp3 over plain http.
|
||||
// Appended after the video profile so the first-entry-wins
|
||||
// ordering for video output codecs is untouched.
|
||||
if (audioProfile)
|
||||
const {
|
||||
'Type': 'Audio',
|
||||
'Container': 'mp3',
|
||||
'AudioCodec': 'mp3',
|
||||
'Protocol': 'http',
|
||||
'Context': 'Streaming',
|
||||
'MaxAudioChannels': '2',
|
||||
},
|
||||
],
|
||||
// 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?>>[
|
||||
{
|
||||
'DirectPlayProfiles': <Map<String, Object?>>[
|
||||
const {
|
||||
'Type': 'Video',
|
||||
'Container': 'mp4,mkv,m4v,webm,mov,ts',
|
||||
'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video',
|
||||
'AudioCodec': 'aac,mp3,mp2,ac3,eac3,flac,opus,vorbis,dts',
|
||||
},
|
||||
// Music containers/codecs mpv plays natively everywhere.
|
||||
if (audioProfile)
|
||||
const {
|
||||
'Type': 'Audio',
|
||||
'Container': 'flac,mp3,ogg,oga,opus,m4a,m4b,aac,alac,wav,aiff,wma,webma',
|
||||
'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma',
|
||||
},
|
||||
],
|
||||
'SubtitleProfiles': const <Map<String, Object?>>[
|
||||
{'Format': 'srt', 'Method': 'External'},
|
||||
|
||||
@@ -144,13 +144,17 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
||||
|
||||
@override
|
||||
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
|
||||
// MediaType stamps the playlist's kind server-side; derive it from the
|
||||
// seed items so music selections create Audio playlists (which is what
|
||||
// fetchPlaylistsPage filters on). Empty seeds keep the Video default.
|
||||
final isMusic = items.isNotEmpty && items.first.kind.isMusic;
|
||||
final response = await _http.post(
|
||||
'/Playlists',
|
||||
queryParameters: {
|
||||
'Name': title,
|
||||
'Ids': items.map((i) => i.id).join(','),
|
||||
'UserId': connection.userId,
|
||||
'MediaType': 'Video',
|
||||
'MediaType': isMusic ? 'Audio' : 'Video',
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
|
||||
@@ -164,21 +164,38 @@ class JellyfinMappers {
|
||||
titleSort: item['SortName'] as String?,
|
||||
summary: item['Overview'] as String?,
|
||||
tagline: _firstString(item['Taglines']),
|
||||
originalTitle: item['OriginalTitle'] as String?,
|
||||
// Music: a compilation track's own performer(s) go into originalTitle
|
||||
// (matching Plex's convention) so MediaItem.trackArtistTitle can prefer
|
||||
// it over the album artist. Only set when it actually differs.
|
||||
originalTitle: item['OriginalTitle'] as String? ?? _trackArtistsOriginalTitle(item),
|
||||
studio: _firstStudioName(item['Studios']),
|
||||
year: item['ProductionYear'] as int?,
|
||||
originallyAvailableAt: jellyfinIsoToYmd(item['PremiereDate'] as String?),
|
||||
contentRating: item['OfficialRating'] as String?,
|
||||
parentId: item['SeasonId'] as String? ?? item['ParentId'] as String?,
|
||||
parentTitle: item['SeasonName'] as String?,
|
||||
// Music mirrors the episode hierarchy, matching Plex's parent chain:
|
||||
// track parent = album (AlbumId / Album), track grandparent = album
|
||||
// artist (AlbumArtists[0] / AlbumArtist), and an *album's* parent is its
|
||||
// artist (Jellyfin album dtos link artists via tags, not ParentId).
|
||||
// Video rows never carry the Album* fields, so the extra fallbacks are
|
||||
// inert for them.
|
||||
parentId:
|
||||
item['SeasonId'] as String? ??
|
||||
item['AlbumId'] as String? ??
|
||||
(kind == MediaKind.album ? _firstAlbumArtistId(item) : null) ??
|
||||
item['ParentId'] as String?,
|
||||
parentTitle:
|
||||
item['SeasonName'] as String? ??
|
||||
item['Album'] as String? ??
|
||||
(kind == MediaKind.album ? item['AlbumArtist'] as String? : null),
|
||||
parentThumbPath: _imagePath(item, 'SeasonId', 'SeasonPrimaryImageTag', 'Primary'),
|
||||
parentIndex: item['ParentIndexNumber'] as int?,
|
||||
index: item['IndexNumber'] as int?,
|
||||
grandparentId: item['SeriesId'] as String?,
|
||||
grandparentTitle: item['SeriesName'] as String?,
|
||||
grandparentId: item['SeriesId'] as String? ?? (kind == MediaKind.track ? _firstAlbumArtistId(item) : null),
|
||||
grandparentTitle:
|
||||
item['SeriesName'] as String? ?? (kind == MediaKind.track ? item['AlbumArtist'] as String? : null),
|
||||
grandparentThumbPath: _seriesPrimaryImage(item),
|
||||
grandparentArtPath: _parentBackdropImage(item) ?? _seriesBackdropImage(item),
|
||||
thumbPath: _selfImagePath(id, item, 'Primary'),
|
||||
thumbPath: _selfImagePath(id, item, 'Primary') ?? _albumPrimaryImage(item),
|
||||
artPath: _selfImagePath(id, item, 'Backdrop'),
|
||||
// Episodes/seasons don't carry their own logo — Jellyfin exposes the
|
||||
// parent's logo via ParentLogoItemId/ParentLogoImageTag, which is
|
||||
@@ -472,6 +489,38 @@ class JellyfinMappers {
|
||||
return _itemImagePath(id, type, tag: tag);
|
||||
}
|
||||
|
||||
/// First album-artist id for Audio/MusicAlbum rows — the music counterpart
|
||||
/// of `SeriesId` in the parent hierarchy.
|
||||
static String? _firstAlbumArtistId(Map<String, dynamic> item) {
|
||||
final albumArtists = item['AlbumArtists'];
|
||||
if (albumArtists is List && albumArtists.isNotEmpty) {
|
||||
final first = albumArtists.first;
|
||||
if (first is Map<String, dynamic>) return first['Id'] as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Per-track performer(s) joined for display, only when they differ from
|
||||
/// the album artist (Jellyfin sets `Artists == [AlbumArtist]` on
|
||||
/// non-compilation tracks, where the value would be redundant).
|
||||
static String? _trackArtistsOriginalTitle(Map<String, dynamic> item) {
|
||||
final artists = stringListFromRaw(item['Artists']);
|
||||
if (artists == null || artists.isEmpty) return null;
|
||||
final joined = artists.join(', ');
|
||||
return joined == item['AlbumArtist'] as String? ? null : joined;
|
||||
}
|
||||
|
||||
/// Album cover fallback for tracks without embedded art. Requires the
|
||||
/// `AlbumPrimaryImageTag` — its presence is Jellyfin's signal that the
|
||||
/// album actually has a primary image, so we never emit a 404-ing URL as
|
||||
/// an item's main thumb.
|
||||
static String? _albumPrimaryImage(Map<String, dynamic> item) {
|
||||
final albumId = item['AlbumId'] as String?;
|
||||
final tag = item['AlbumPrimaryImageTag'] as String?;
|
||||
if (albumId == null || tag == null) return null;
|
||||
return _itemImagePath(albumId, 'Primary', tag: tag);
|
||||
}
|
||||
|
||||
static String? _seriesPrimaryImage(Map<String, dynamic> item) {
|
||||
final seriesId = item['SeriesId'] as String?;
|
||||
if (seriesId == null) return null;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/// [mediaSegment] selects the endpoint family: Jellyfin serves the identical
|
||||
/// static-stream contract under `/Videos/{id}/stream` and `/Audio/{id}/stream`
|
||||
/// — music track playback passes `'Audio'`.
|
||||
String buildJellyfinDirectStreamUrl({
|
||||
required String baseUrl,
|
||||
required String accessToken,
|
||||
required String deviceId,
|
||||
required String itemId,
|
||||
String mediaSegment = 'Videos',
|
||||
String? container,
|
||||
String? mediaSourceId,
|
||||
String? playSessionId,
|
||||
@@ -20,7 +24,7 @@ String buildJellyfinDirectStreamUrl({
|
||||
'AudioStreamIndex': ?audioStreamIndex?.toString(),
|
||||
};
|
||||
final encodedItem = Uri.encodeComponent(itemId);
|
||||
return '$baseUrl/Videos/$encodedItem/stream?${_encodeQuery(params)}';
|
||||
return '$baseUrl/$mediaSegment/$encodedItem/stream?${_encodeQuery(params)}';
|
||||
}
|
||||
|
||||
String buildJellyfinTrickplayTileUrl({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_source_info.dart';
|
||||
import '../media/media_version.dart';
|
||||
import '../models/audio_quality_preset.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
|
||||
@@ -28,6 +29,12 @@ class PlaybackInitializationOptions {
|
||||
/// server to transcode when supported.
|
||||
final TranscodeQualityPreset qualityPreset;
|
||||
|
||||
/// Music transcode preset, consulted only when [metadata] is a
|
||||
/// [MediaKind.track]. `original` (or null) direct-plays; anything else asks
|
||||
/// for a bitrate-capped audio transcode. [qualityPreset] is ignored for
|
||||
/// tracks — video presets are resolution-shaped.
|
||||
final AudioQualityPreset? audioQualityPreset;
|
||||
|
||||
/// Audio stream id forwarded to the transcoder. `null` means "let the
|
||||
/// server pick".
|
||||
final int? selectedAudioStreamId;
|
||||
@@ -45,6 +52,7 @@ class PlaybackInitializationOptions {
|
||||
this.selectedMediaSourceId,
|
||||
this.preferredVersionSignature,
|
||||
this.qualityPreset = TranscodeQualityPreset.original,
|
||||
this.audioQualityPreset,
|
||||
this.selectedAudioStreamId,
|
||||
this.sessionIdentifier,
|
||||
this.transcodeSessionId,
|
||||
|
||||
+357
-70
@@ -3,12 +3,14 @@ import '../utils/isolate_helper.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../media/download_resolution.dart';
|
||||
import '../media/library_filter_result.dart';
|
||||
import '../media/library_first_character.dart';
|
||||
import '../media/library_query.dart';
|
||||
import '../media/live_tv_support.dart';
|
||||
import '../media/lyrics.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
@@ -50,9 +52,11 @@ import '../models/plex/plex_match_result.dart';
|
||||
import '../utils/codec_utils.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../media/media_sort.dart';
|
||||
import '../models/audio_quality_preset.dart';
|
||||
import '../models/plex/plex_video_playback_data.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../utils/device_identity.dart';
|
||||
import '../utils/lrc_parser.dart';
|
||||
import '../utils/failover_http_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/media_server_retry.dart';
|
||||
@@ -66,6 +70,7 @@ import '../i18n/strings.g.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
import 'api_cache.dart';
|
||||
import 'plex_api_cache.dart';
|
||||
import 'plex_constants.dart';
|
||||
import 'plex_mappers.dart';
|
||||
import 'plex_playback_mapper.dart';
|
||||
import 'playback_initialization_types.dart';
|
||||
@@ -1285,14 +1290,15 @@ class PlexClient
|
||||
|
||||
/// Search across all libraries including individually shared items.
|
||||
/// Uses /library/search (same endpoint as Plex Web) which finds shared content.
|
||||
/// Only returns movies and shows, filtering out other types.
|
||||
/// Only returns movies, shows, and music (artists/albums/tracks), filtering
|
||||
/// out other types.
|
||||
Future<List<PlexMetadataDto>> _search(String query, {int limit = 100}) async {
|
||||
final response = await _getWithFailover(
|
||||
'/library/search',
|
||||
queryParameters: {
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
'searchTypes': 'movies,tv',
|
||||
'searchTypes': 'movies,tv,music',
|
||||
'includeCollections': 1,
|
||||
'includeExternalMedia': 1,
|
||||
'X-Plex-Container-Size': limit,
|
||||
@@ -1314,7 +1320,8 @@ class PlexClient
|
||||
if (metadata is! Map<String, dynamic>) continue;
|
||||
|
||||
final type = metadata['type'] as String?;
|
||||
if (type != 'movie' && type != 'show') continue;
|
||||
const allowedTypes = {'movie', 'show', 'artist', 'album', 'track'};
|
||||
if (!allowedTypes.contains(type)) continue;
|
||||
|
||||
results.add(_createTaggedMetadata(metadata));
|
||||
} catch (e) {
|
||||
@@ -1574,15 +1581,7 @@ class PlexClient
|
||||
|
||||
Future<MediaFileInfo?> _fetchFileInfo(String ratingKey) async {
|
||||
try {
|
||||
final data = await fetchWithCacheFirst<Map<String, dynamic>>(
|
||||
cacheKey: '/library/metadata/$ratingKey',
|
||||
networkCall: () =>
|
||||
_http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
|
||||
parseCache: (cached) => cached as Map<String, dynamic>?,
|
||||
parseResponse: (response) => response.data as Map<String, dynamic>?,
|
||||
);
|
||||
final metadataJson = _getFirstMetadataJsonFromData(data);
|
||||
|
||||
final metadataJson = await _fetchRawMetadataJsonCacheFirst(ratingKey);
|
||||
return parsePlexFileInfoFromJson(metadataJson);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get file info: $e');
|
||||
@@ -1590,6 +1589,25 @@ class PlexClient
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache-first raw metadata JSON for [ratingKey]. Serves the shared
|
||||
/// `/library/metadata/{id}` cache row when a detail/playback flow already
|
||||
/// warmed it (the common case — no extra round-trip); on a miss it fetches
|
||||
/// with the full playback query params so the row it caches stays complete
|
||||
/// for the cache-only readers ([fetchPlaybackExtrasFromCacheOnly],
|
||||
/// [fetchCachedMediaSourceInfo]).
|
||||
Future<Map<String, dynamic>?> _fetchRawMetadataJsonCacheFirst(String ratingKey) async {
|
||||
final data = await fetchWithCacheFirst<Map<String, dynamic>>(
|
||||
cacheKey: '/library/metadata/$ratingKey',
|
||||
networkCall: () => _http.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1},
|
||||
),
|
||||
parseCache: (cached) => cached as Map<String, dynamic>?,
|
||||
parseResponse: (response) => response.data as Map<String, dynamic>?,
|
||||
);
|
||||
return _getFirstMetadataJsonFromData(data);
|
||||
}
|
||||
|
||||
/// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`).
|
||||
///
|
||||
/// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or
|
||||
@@ -1780,7 +1798,19 @@ class PlexClient
|
||||
];
|
||||
}
|
||||
try {
|
||||
final response = await _getWithFailover('/library/sections/$sectionId/sorts');
|
||||
// Music sections serve per-type sort lists: the bare endpoint returns
|
||||
// the section default (artist) sorts; `?type=9|10` returns album/track
|
||||
// sorts. Video libraries keep the bare call — their section type
|
||||
// already pins the list.
|
||||
final musicType = switch (libraryType?.toLowerCase()) {
|
||||
'album' => PlexMetadataType.album,
|
||||
'track' => PlexMetadataType.track,
|
||||
_ => null,
|
||||
};
|
||||
final response = await _getWithFailover(
|
||||
'/library/sections/$sectionId/sorts',
|
||||
queryParameters: musicType == null ? null : {'type': musicType},
|
||||
);
|
||||
final sorts = _extractDirectoryList(response, MediaSort.fromJson);
|
||||
|
||||
// Fallback: return common sort options if API doesn't provide them
|
||||
@@ -2115,19 +2145,25 @@ class PlexClient
|
||||
return createPlaylistFromUri(title: title);
|
||||
}
|
||||
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
|
||||
return createPlaylistFromUri(title: title, uri: uri);
|
||||
return createPlaylistFromUri(title: title, uri: uri, type: items.first.kind.isMusic ? 'audio' : 'video');
|
||||
}
|
||||
|
||||
/// Create a new playlist
|
||||
/// [title] - Name of the playlist
|
||||
/// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234")
|
||||
/// [playQueueId] - Optional play queue ID to create playlist from
|
||||
/// [type] - Plex playlist type ('video' or 'audio' for music items)
|
||||
///
|
||||
/// Errors propagate to the caller (matches the [MediaServerClient]
|
||||
/// contract — throw on HTTP/transport failures, return `null` only when
|
||||
/// the server replied 2xx but with no usable playlist payload).
|
||||
Future<MediaPlaylist?> createPlaylistFromUri({required String title, String? uri, int? playQueueId}) async {
|
||||
final queryParams = <String, dynamic>{'type': 'video', 'title': title, 'smart': '0'};
|
||||
Future<MediaPlaylist?> createPlaylistFromUri({
|
||||
required String title,
|
||||
String? uri,
|
||||
int? playQueueId,
|
||||
String type = 'video',
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{'type': type, 'title': title, 'smart': '0'};
|
||||
|
||||
if (uri != null) {
|
||||
queryParams['uri'] = uri;
|
||||
@@ -3009,53 +3045,114 @@ class PlexClient
|
||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||
offsetMs: offsetMs,
|
||||
);
|
||||
|
||||
final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
||||
|
||||
final decisionClient = MediaServerHttpClient(
|
||||
connectTimeout: MediaServerTimeouts.connect,
|
||||
receiveTimeout: MediaServerTimeouts.receive,
|
||||
defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'},
|
||||
return await _runTranscodeDecision(
|
||||
startEndpoint: _videoTranscodeStartEndpoint,
|
||||
allParams: allParams,
|
||||
isOriginal: preset.isOriginal,
|
||||
);
|
||||
try {
|
||||
final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString';
|
||||
final decisionResponse = await decisionClient.get(decisionUrl);
|
||||
|
||||
final decisionBody = decisionResponse.data?.toString() ?? '<empty>';
|
||||
appLogger.i(
|
||||
'Transcode decision [${decisionResponse.statusCode}] body: '
|
||||
'${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}',
|
||||
);
|
||||
|
||||
if (decisionResponse.statusCode != 200) {
|
||||
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
}
|
||||
|
||||
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: preset.isOriginal);
|
||||
if (outcome == TranscodeDecisionOutcome.failed) {
|
||||
return (startPath: null, outcome: outcome);
|
||||
}
|
||||
|
||||
return (startPath: _buildTranscodeStartPathFromParams(allParams), outcome: outcome);
|
||||
} finally {
|
||||
decisionClient.close();
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
}
|
||||
}
|
||||
|
||||
String _buildTranscodeStartPathFromParams(Map<String, String> params) {
|
||||
/// Build a music transcode stream URL (decision + start path).
|
||||
///
|
||||
/// Mirrors [buildTranscodeStartPath] for audio tracks: the same
|
||||
/// decision → start handshake against `/music/:/transcode/universal`,
|
||||
/// with a bitrate-capped MP3 target instead of the video HTTP/MKV
|
||||
/// target. No subtitle/copyts params — those are video-only.
|
||||
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildMusicTranscodeStartPath({
|
||||
required String ratingKey,
|
||||
required int mediaIndex,
|
||||
int partIndex = 0,
|
||||
required AudioQualityPreset preset,
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
}) async {
|
||||
try {
|
||||
final allParams = _buildMusicTranscodeParams(
|
||||
ratingKey: ratingKey,
|
||||
mediaIndex: mediaIndex,
|
||||
partIndex: partIndex,
|
||||
preset: preset,
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
);
|
||||
return await _runTranscodeDecision(
|
||||
startEndpoint: _musicTranscodeStartEndpoint,
|
||||
allParams: allParams,
|
||||
isOriginal: preset.isOriginal,
|
||||
);
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to build music transcode start path', error: e, stackTrace: st);
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
}
|
||||
}
|
||||
|
||||
static const String _videoTranscodeStartEndpoint = '/video/:/transcode/universal/start';
|
||||
static const String _musicTranscodeStartEndpoint = '/music/:/transcode/universal/start.mp3';
|
||||
|
||||
/// Shared decision plumbing for the video and music transcode flows: GET
|
||||
/// the sibling `decision` endpoint with the exact start params, parse the
|
||||
/// outcome via [_parseTranscodeDecisionOutcome], and hand back the start
|
||||
/// path (token stripped) on success. [startEndpoint] is the start path the
|
||||
/// stream will use, including any container extension (`start` /
|
||||
/// `start.mp3`).
|
||||
Future<({String? startPath, TranscodeDecisionOutcome outcome})> _runTranscodeDecision({
|
||||
required String startEndpoint,
|
||||
required Map<String, String> allParams,
|
||||
required bool isOriginal,
|
||||
}) async {
|
||||
final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
||||
final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision';
|
||||
|
||||
final decisionClient = MediaServerHttpClient(
|
||||
connectTimeout: MediaServerTimeouts.connect,
|
||||
receiveTimeout: MediaServerTimeouts.receive,
|
||||
defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'},
|
||||
);
|
||||
try {
|
||||
final decisionUrl = '${config.baseUrl}$decisionEndpoint?$queryString';
|
||||
final decisionResponse = await decisionClient.get(decisionUrl);
|
||||
|
||||
final decisionBody = decisionResponse.data?.toString() ?? '<empty>';
|
||||
appLogger.i(
|
||||
'Transcode decision [${decisionResponse.statusCode}] body: '
|
||||
'${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}',
|
||||
);
|
||||
|
||||
if (decisionResponse.statusCode != 200) {
|
||||
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
}
|
||||
|
||||
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal);
|
||||
if (outcome == TranscodeDecisionOutcome.failed) {
|
||||
return (startPath: null, outcome: outcome);
|
||||
}
|
||||
|
||||
return (startPath: _buildTranscodeStartPathFromParams(allParams, endpoint: startEndpoint), outcome: outcome);
|
||||
} finally {
|
||||
decisionClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
String _buildTranscodeStartPathFromParams(
|
||||
Map<String, String> params, {
|
||||
String endpoint = _videoTranscodeStartEndpoint,
|
||||
}) {
|
||||
final startParams = Map<String, String>.from(params)..remove('X-Plex-Token');
|
||||
final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
||||
return '/video/:/transcode/universal/start?$startQuery';
|
||||
return '$endpoint?$startQuery';
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
String buildTranscodeStartPathFromParamsForTesting(Map<String, String> params) {
|
||||
return _buildTranscodeStartPathFromParams(params);
|
||||
String buildTranscodeStartPathFromParamsForTesting(
|
||||
Map<String, String> params, {
|
||||
String endpoint = _videoTranscodeStartEndpoint,
|
||||
}) {
|
||||
return _buildTranscodeStartPathFromParams(params, endpoint: endpoint);
|
||||
}
|
||||
|
||||
Map<String, String> _buildTranscodeParams({
|
||||
@@ -3189,6 +3286,63 @@ class PlexClient
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _buildMusicTranscodeParams({
|
||||
required String ratingKey,
|
||||
required int mediaIndex,
|
||||
int partIndex = 0,
|
||||
required AudioQualityPreset preset,
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
}) {
|
||||
// The musicProfile transcode target is required: our `Generic` base
|
||||
// platform ships no targets, and without one the server rejects the
|
||||
// decision with code 4005. MP3 matches Plex Web's music transcode
|
||||
// container and plays everywhere.
|
||||
const clientProfileExtra =
|
||||
'add-transcode-target(type=musicProfile&context=streaming'
|
||||
'&protocol=http&container=mp3&audioCodec=mp3)';
|
||||
|
||||
return <String, String>{
|
||||
'hasMDE': '1',
|
||||
'path': '/library/metadata/$ratingKey',
|
||||
'mediaIndex': mediaIndex.toString(),
|
||||
'partIndex': partIndex.toString(),
|
||||
'protocol': 'http',
|
||||
'directPlay': '0',
|
||||
'directStream': '0',
|
||||
if (preset.bitrateKbps != null) 'musicBitrate': preset.bitrateKbps.toString(),
|
||||
'session': transcodeSessionId,
|
||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
||||
'X-Plex-Client-Profile-Extra': clientProfileExtra,
|
||||
'X-Plex-Product': config.product,
|
||||
'X-Plex-Version': config.version,
|
||||
'X-Plex-Client-Identifier': config.clientIdentifier,
|
||||
'X-Plex-Platform': _transcodePlatformName(),
|
||||
if (config.device != null) 'X-Plex-Device': config.device!,
|
||||
if (config.deviceName != null) 'X-Plex-Device-Name': config.deviceName!,
|
||||
if (config.token != null) 'X-Plex-Token': config.token!,
|
||||
};
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Map<String, String> buildMusicTranscodeParamsForTesting({
|
||||
required String ratingKey,
|
||||
required int mediaIndex,
|
||||
int partIndex = 0,
|
||||
required AudioQualityPreset preset,
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
}) {
|
||||
return _buildMusicTranscodeParams(
|
||||
ratingKey: ratingKey,
|
||||
mediaIndex: mediaIndex,
|
||||
partIndex: partIndex,
|
||||
preset: preset,
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Platform name Plex Media Server accepts on the transcode decision
|
||||
/// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400,
|
||||
/// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected.
|
||||
@@ -3367,12 +3521,34 @@ class PlexClient
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||
// Albums parent their tracks directly and `/grandchildren` returns
|
||||
// nothing for them — branch to `/children`. Artists and shows/seasons
|
||||
// one-shot via `/grandchildren` (artist → every track, show/season →
|
||||
// every episode). The kind lookup rides the cached metadata row
|
||||
// (cache-first — detail screens pre-warm it), so the common path adds
|
||||
// no extra round-trip.
|
||||
if (await _fetchItemKind(parentId) == MediaKind.album) {
|
||||
return fetchChildren(parentId);
|
||||
}
|
||||
final leaves = await _fetchAllPages(
|
||||
(start, size, abort) => _getGrandchildrenPage(parentId, start: start, size: size, abort: abort),
|
||||
);
|
||||
return leaves.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
}
|
||||
|
||||
/// Item kind for [ratingKey] via the cache-first metadata row. Returns
|
||||
/// [MediaKind.unknown] when the item can't be resolved so callers fall
|
||||
/// back to their default branch.
|
||||
Future<MediaKind> _fetchItemKind(String ratingKey) async {
|
||||
try {
|
||||
final metadataJson = await _fetchRawMetadataJsonCacheFirst(ratingKey);
|
||||
return MediaKind.fromString(metadataJson?['type'] as String?);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to resolve item kind for $ratingKey', error: e);
|
||||
return MediaKind.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
|
||||
String parentId, {
|
||||
@@ -3394,6 +3570,76 @@ class PlexClient
|
||||
@override
|
||||
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async => null;
|
||||
|
||||
/// Plex artists are folder-parents of their albums, so both music child
|
||||
/// listings are plain `/library/metadata/{id}/children` fetches.
|
||||
@override
|
||||
Future<List<MediaItem>> fetchArtistAlbums(String artistId) => fetchChildren(artistId);
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchAlbumTracks(String albumId) => fetchChildren(albumId);
|
||||
|
||||
/// Plex "instant mix": a station play queue seeded from [itemId]. The
|
||||
/// station uri's trailing `?type=10` (track results) is part of the
|
||||
/// station path and rides inside the encoded uri value. Consumed as a
|
||||
/// plain track list — music playback is queue-managed client-side.
|
||||
@override
|
||||
Future<List<MediaItem>> fetchInstantMix(String itemId, {int limit = 100}) async {
|
||||
final stationUri = '${await buildMetadataUri(itemId)}/station/${const Uuid().v4()}?type=${PlexMetadataType.track}';
|
||||
final queue = await createPlayQueue(uri: stationUri, type: 'audio');
|
||||
final tracks = queue?.items ?? const <MediaItem>[];
|
||||
return tracks.length > limit ? tracks.sublist(0, limit) : tracks;
|
||||
}
|
||||
|
||||
/// Plex lyrics: sidecar `.lrc`/`.txt` files surface as track Part streams
|
||||
/// with `streamType 4`; the raw text lives at the stream's `key`
|
||||
/// (`/library/streams/{id}`). Returns `null` when the track has no lyric
|
||||
/// stream (or it can't be fetched) — lyrics are decorative, so errors
|
||||
/// degrade to "none" rather than failing the caller.
|
||||
@override
|
||||
Future<Lyrics?> fetchLyrics(MediaItem track) async {
|
||||
try {
|
||||
final metadataJson = await _fetchRawMetadataJsonCacheFirst(track.id);
|
||||
final streamKey = _findLyricStreamKey(metadataJson);
|
||||
if (streamKey == null) return null;
|
||||
final response = await _getWithFailover(streamKey);
|
||||
final raw = response.data;
|
||||
if (raw is! String) return null;
|
||||
return parseLrc(raw);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to fetch lyrics for ${track.id}', error: e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the `/library/streams/{id}` key of [metadataJson]'s lyric stream
|
||||
/// ([PlexStreamType.lyrics]), preferring `lrc` (synced) over other
|
||||
/// formats (`txt`).
|
||||
String? _findLyricStreamKey(Map<String, dynamic>? metadataJson) {
|
||||
final mediaList = metadataJson?['Media'];
|
||||
if (mediaList is! List) return null;
|
||||
String? fallbackKey;
|
||||
for (final media in mediaList) {
|
||||
if (media is! Map) continue;
|
||||
final parts = media['Part'];
|
||||
if (parts is! List) continue;
|
||||
for (final part in parts) {
|
||||
if (part is! Map) continue;
|
||||
final streams = part['Stream'];
|
||||
if (streams is! List) continue;
|
||||
for (final stream in streams) {
|
||||
if (stream is! Map) continue;
|
||||
if (flexibleInt(stream['streamType']) != PlexStreamType.lyrics) continue;
|
||||
final key = stream['key'] as String?;
|
||||
if (key == null || key.isEmpty) continue;
|
||||
final format = ((stream['format'] ?? stream['codec']) as String?)?.toLowerCase();
|
||||
if (format == 'lrc') return key;
|
||||
fallbackKey ??= key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallbackKey;
|
||||
}
|
||||
|
||||
/// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata,
|
||||
/// then either runs the transcode-decision flow or returns the direct-play
|
||||
/// URL. External subtitle tracks are absolutized with the server's auth
|
||||
@@ -3414,8 +3660,39 @@ class PlexClient
|
||||
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
||||
}
|
||||
|
||||
final wantTranscode = !options.qualityPreset.isOriginal;
|
||||
// Tracks consult the music preset — [qualityPreset] is video-shaped
|
||||
// (resolution/videoQuality) and is ignored for audio.
|
||||
final isTrack = options.metadata.kind == MediaKind.track;
|
||||
final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
|
||||
final wantTranscode = isTrack ? !audioPreset.isOriginal : !options.qualityPreset.isOriginal;
|
||||
if (wantTranscode && options.sessionIdentifier != null && options.transcodeSessionId != null) {
|
||||
if (isTrack) {
|
||||
final result = await buildMusicTranscodeStartPath(
|
||||
ratingKey: options.metadata.id,
|
||||
mediaIndex: data.selectedMediaIndex,
|
||||
partIndex: data.selectedPartIndex,
|
||||
preset: audioPreset,
|
||||
sessionIdentifier: options.sessionIdentifier!,
|
||||
transcodeSessionId: options.transcodeSessionId!,
|
||||
);
|
||||
|
||||
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
||||
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
|
||||
return PlaybackInitializationResult(
|
||||
availableVersions: data.availableVersions,
|
||||
videoUrl: transcodeUrl,
|
||||
mediaInfo: data.mediaInfo,
|
||||
isOffline: false,
|
||||
isTranscoding: true,
|
||||
playMethod: 'Transcode',
|
||||
playSessionId: options.sessionIdentifier,
|
||||
selectedMediaIndex: data.selectedMediaIndex,
|
||||
);
|
||||
}
|
||||
|
||||
return _transcodeFallbackResult(data, result.outcome, options);
|
||||
}
|
||||
|
||||
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
|
||||
final resumeOffsetMs = options.metadata.viewOffsetMs;
|
||||
final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo);
|
||||
@@ -3448,24 +3725,7 @@ class PlexClient
|
||||
);
|
||||
}
|
||||
|
||||
// Decision failed or said direct-play only — fall through to direct-play path
|
||||
// and surface the fallback reason so the UI can notify the user.
|
||||
final fallbackReason = result.outcome == TranscodeDecisionOutcome.directPlayOnly
|
||||
? TranscodeFallbackReason.directPlayOnly
|
||||
: TranscodeFallbackReason.decisionFailed;
|
||||
appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}');
|
||||
return PlaybackInitializationResult(
|
||||
availableVersions: data.availableVersions,
|
||||
videoUrl: data.videoUrl,
|
||||
mediaInfo: data.mediaInfo,
|
||||
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
|
||||
isOffline: false,
|
||||
isTranscoding: false,
|
||||
fallbackReason: fallbackReason,
|
||||
playMethod: 'DirectPlay',
|
||||
playSessionId: options.sessionIdentifier,
|
||||
selectedMediaIndex: data.selectedMediaIndex,
|
||||
);
|
||||
return _transcodeFallbackResult(data, result.outcome, options);
|
||||
}
|
||||
|
||||
return PlaybackInitializationResult(
|
||||
@@ -3484,6 +3744,33 @@ class PlexClient
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct-play result for a transcode decision that fell back (failed or
|
||||
/// said direct-play only), surfacing the reason so the UI can notify the
|
||||
/// user. Shared by the video and music branches of
|
||||
/// [getPlaybackInitialization].
|
||||
PlaybackInitializationResult _transcodeFallbackResult(
|
||||
PlexVideoPlaybackData data,
|
||||
TranscodeDecisionOutcome outcome,
|
||||
PlaybackInitializationOptions options,
|
||||
) {
|
||||
final fallbackReason = outcome == TranscodeDecisionOutcome.directPlayOnly
|
||||
? TranscodeFallbackReason.directPlayOnly
|
||||
: TranscodeFallbackReason.decisionFailed;
|
||||
appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}');
|
||||
return PlaybackInitializationResult(
|
||||
availableVersions: data.availableVersions,
|
||||
videoUrl: data.videoUrl,
|
||||
mediaInfo: data.mediaInfo,
|
||||
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
|
||||
isOffline: false,
|
||||
isTranscoding: false,
|
||||
fallbackReason: fallbackReason,
|
||||
playMethod: 'DirectPlay',
|
||||
playSessionId: options.sessionIdentifier,
|
||||
selectedMediaIndex: data.selectedMediaIndex,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pick the audio stream ID to send to the transcoder. Preference order:
|
||||
/// explicit [explicit] → audio track with `selected == true` → first → null.
|
||||
int? _resolveAudioStreamId(int? explicit, MediaSourceInfo? info) {
|
||||
|
||||
@@ -8,6 +8,7 @@ class PlexStreamType {
|
||||
static const int video = 1;
|
||||
static const int audio = 2;
|
||||
static const int subtitle = 3;
|
||||
static const int lyrics = 4;
|
||||
}
|
||||
|
||||
/// Plex metadata `type` integer codes — the value that goes in the
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'base_shared_preferences_service.dart';
|
||||
import 'device_performance.dart';
|
||||
export 'base_shared_preferences_service.dart'
|
||||
show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref;
|
||||
import '../models/audio_quality_preset.dart';
|
||||
import '../models/transcode_quality_preset.dart';
|
||||
import '../navigation/navigation_tabs.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
@@ -420,6 +421,11 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
values: TranscodeQualityPreset.values,
|
||||
defaultValue: TranscodeQualityPreset.original,
|
||||
);
|
||||
static const musicQualityPreset = EnumPref<AudioQualityPreset>(
|
||||
'music_quality_preset',
|
||||
values: AudioQualityPreset.values,
|
||||
defaultValue: AudioQualityPreset.original,
|
||||
);
|
||||
static const autoPlayNextEpisode = BoolPref('auto_play_next_episode', defaultValue: true);
|
||||
static const useExoPlayer = BoolPref('use_exoplayer', defaultValue: true);
|
||||
static const startupSection = EnumPref<NavigationTabId>(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import '../media/lyrics.dart';
|
||||
|
||||
/// Matches `[mm:ss]`, `[mm:ss.x]`, `[mm:ss.xx]`, `[mm:ss.xxx]` timestamps
|
||||
/// (multiple per line allowed, per the LRC spec).
|
||||
final RegExp _timestamp = RegExp(r'\[(\d{1,3}):(\d{1,2})(?:\.(\d{1,3}))?\]');
|
||||
|
||||
/// Metadata/ID tags like `[ar:Artist]`, `[offset:+120]`.
|
||||
final RegExp _idTag = RegExp(r'^\[([a-zA-Z#]+):(.*)\]$');
|
||||
|
||||
/// Parse LRC content into [Lyrics]. Lines with `[mm:ss.xx]` timestamps become
|
||||
/// synced lines (a line with multiple timestamps is emitted once per
|
||||
/// timestamp); the `[offset:±ms]` tag is applied. Input with no timestamps at
|
||||
/// all falls back to unsynced plain text. Returns `null` for blank input.
|
||||
Lyrics? parseLrc(String raw) {
|
||||
final lines = <LyricLine>[];
|
||||
final plain = <String>[];
|
||||
var offsetMs = 0;
|
||||
|
||||
for (final rawLine in raw.split(RegExp(r'\r?\n'))) {
|
||||
final line = rawLine.trim();
|
||||
if (line.isEmpty) continue;
|
||||
|
||||
final idTag = _idTag.firstMatch(line);
|
||||
if (idTag != null && !_timestamp.hasMatch(line)) {
|
||||
if (idTag.group(1)!.toLowerCase() == 'offset') {
|
||||
offsetMs = int.tryParse(idTag.group(2)!.trim()) ?? 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final stamps = _timestamp.allMatches(line).toList();
|
||||
if (stamps.isEmpty) {
|
||||
plain.add(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
final text = line.substring(stamps.last.end).trim();
|
||||
for (final m in stamps) {
|
||||
final minutes = int.parse(m.group(1)!);
|
||||
final seconds = int.parse(m.group(2)!);
|
||||
// Fractional part scales by digit count: ".5" = 500ms, ".50" = 500ms.
|
||||
final frac = m.group(3);
|
||||
final fracMs = frac == null ? 0 : (int.parse(frac) * 1000 ~/ _pow10(frac.length)).clamp(0, 999);
|
||||
final startMs = (minutes * 60 + seconds) * 1000 + fracMs - offsetMs;
|
||||
lines.add(LyricLine(text: text, startMs: startMs < 0 ? 0 : startMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.isNotEmpty) {
|
||||
lines.sort((a, b) => a.startMs!.compareTo(b.startMs!));
|
||||
return Lyrics(synced: true, lines: lines);
|
||||
}
|
||||
if (plain.isNotEmpty) {
|
||||
return Lyrics(synced: false, lines: [for (final t in plain) LyricLine(text: t)]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int _pow10(int n) => switch (n) {
|
||||
1 => 10,
|
||||
2 => 100,
|
||||
_ => 1000,
|
||||
};
|
||||
@@ -188,8 +188,13 @@ void main() {
|
||||
|
||||
expect(results.map((item) => item.id), ['jf-show']);
|
||||
expect(plexRequests.single.queryParameters['limit'], '100');
|
||||
expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv');
|
||||
expect(jellyfinRequests.single.queryParameters['Limit'], '100');
|
||||
expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv,music');
|
||||
// Jellyfin search fans out to /Items plus a best-effort /Artists call
|
||||
// (500 above → treated as empty).
|
||||
final jfItemsRequest = jellyfinRequests.singleWhere((url) => url.path == '/Items');
|
||||
expect(jfItemsRequest.queryParameters['Limit'], '100');
|
||||
final jfArtistsRequest = jellyfinRequests.singleWhere((url) => url.path == '/Artists');
|
||||
expect(jfArtistsRequest.queryParameters['searchTerm'], 'The Boys');
|
||||
});
|
||||
|
||||
test('getOnDeckFromAllServers forwards preview limit to clients', () async {
|
||||
|
||||
@@ -90,6 +90,26 @@ void main() {
|
||||
expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/stream');
|
||||
});
|
||||
|
||||
test('buildAudioDirectStreamUrl targets /Audio with the same static-stream contract', () {
|
||||
final url = client.buildAudioDirectStreamUrl('track-7');
|
||||
final uri = Uri.parse(url);
|
||||
|
||||
expect(uri.path, '/Audio/track-7/stream');
|
||||
expect(uri.queryParameters['Static'], 'true');
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
expect(uri.queryParameters['DeviceId'], 'dev-xyz');
|
||||
expect(uri.queryParameters.containsKey('Container'), isFalse);
|
||||
expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse);
|
||||
});
|
||||
|
||||
test('buildAudioDirectStreamUrl appends Container and MediaSourceId when provided', () {
|
||||
final url = client.buildAudioDirectStreamUrl('track-7', container: 'flac', mediaSourceId: 'src-9');
|
||||
final uri = Uri.parse(url);
|
||||
|
||||
expect(uri.queryParameters['Container'], 'flac');
|
||||
expect(uri.queryParameters['MediaSourceId'], 'src-9');
|
||||
});
|
||||
|
||||
test('buildDirectStreamUrl canonicalizes a mixed-case scheme from stored config', () async {
|
||||
// This URL bypasses Dart's Uri normalization on its way to the player,
|
||||
// and FFmpeg's protocol lookup is case-sensitive — a stored
|
||||
@@ -2952,7 +2972,8 @@ void main() {
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['ParentId'], 'show-1');
|
||||
expect(requestUri!.queryParameters['Recursive'], 'true');
|
||||
expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode');
|
||||
// Audio rides along so albums/artists/audio playlists expand to tracks.
|
||||
expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Audio');
|
||||
expect(requestUri!.queryParameters['StartIndex'], '20');
|
||||
expect(requestUri!.queryParameters['Limit'], '10');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/services/jellyfin_mappers.dart';
|
||||
|
||||
const _serverId = 'jf-machine-1';
|
||||
|
||||
/// Captured (trimmed) from a live Jellyfin 10.11 server — an `Audio` row
|
||||
/// from `/Items?AlbumIds=...&IncludeItemTypes=Audio`.
|
||||
Map<String, dynamic> _audioJson() => {
|
||||
'Name': 'Intro (Live)',
|
||||
'ServerId': '9c23dc6d65044485b4ee44287e723c90',
|
||||
'Id': '425f9ab168792a3be733d169b770853f',
|
||||
'HasLyrics': false,
|
||||
'Container': 'flac',
|
||||
'SortName': '0001 - 0001 - Intro (Live)',
|
||||
'PremiereDate': '2022-01-01T00:00:00.0000000Z',
|
||||
'RunTimeTicks': 120000000,
|
||||
'ProductionYear': 2022,
|
||||
'IndexNumber': 1,
|
||||
'ParentIndexNumber': 1,
|
||||
'Type': 'Audio',
|
||||
'UserData': {'PlaybackPositionTicks': 0, 'PlayCount': 0, 'IsFavorite': false, 'Played': false},
|
||||
'Artists': ['The Synth Pops'],
|
||||
'ArtistItems': [
|
||||
{'Name': 'The Synth Pops', 'Id': 'a603621309dc866c91b6c5fe10cee64d'},
|
||||
],
|
||||
'Album': 'Live at Testhalle',
|
||||
'AlbumId': '27511f928761c3f5c080d43d6799ea09',
|
||||
'AlbumPrimaryImageTag': '233dccb8ad84d8ac473dbffb86c35e6c',
|
||||
'AlbumArtist': 'The Synth Pops',
|
||||
'AlbumArtists': [
|
||||
{'Name': 'The Synth Pops', 'Id': 'a603621309dc866c91b6c5fe10cee64d'},
|
||||
],
|
||||
'ImageTags': {'Primary': '1ed1281fd45ff9b8b5ad62b6f6a34d17'},
|
||||
'BackdropImageTags': <String>[],
|
||||
'MediaType': 'Audio',
|
||||
};
|
||||
|
||||
/// Captured (trimmed) `MusicAlbum` row from the same server.
|
||||
Map<String, dynamic> _albumJson() => {
|
||||
'Name': 'Live at Testhalle',
|
||||
'Id': '27511f928761c3f5c080d43d6799ea09',
|
||||
'SortName': 'live at testhalle',
|
||||
'PremiereDate': '2022-01-01T00:00:00.0000000Z',
|
||||
'RunTimeTicks': 1610000000,
|
||||
'ProductionYear': 2022,
|
||||
'IsFolder': true,
|
||||
'Type': 'MusicAlbum',
|
||||
'UserData': {'PlayCount': 0, 'IsFavorite': false, 'Played': false},
|
||||
'RecursiveItemCount': 8,
|
||||
'ChildCount': 8,
|
||||
'Artists': ['The Synth Pops'],
|
||||
'AlbumArtist': 'The Synth Pops',
|
||||
'AlbumArtists': [
|
||||
{'Name': 'The Synth Pops', 'Id': 'a603621309dc866c91b6c5fe10cee64d'},
|
||||
],
|
||||
'ImageTags': {'Primary': '233dccb8ad84d8ac473dbffb86c35e6c'},
|
||||
'MediaType': 'Unknown',
|
||||
};
|
||||
|
||||
JellyfinConnection _conn() => JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Home',
|
||||
serverMachineId: 'srv-1',
|
||||
userId: 'user-1',
|
||||
userName: 'edde',
|
||||
accessToken: 'tok-abc',
|
||||
deviceId: 'dev-xyz',
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('JellyfinMappers.mediaItem music mapping', () {
|
||||
test('maps an Audio track with album/artist hierarchy fallbacks', () {
|
||||
final item = JellyfinMappers.mediaItem(_audioJson(), serverId: ServerId(_serverId), absolutizer: null)!;
|
||||
|
||||
expect(item.kind, MediaKind.track);
|
||||
expect(item.title, 'Intro (Live)');
|
||||
// Track parent = album, grandparent = album artist (episode-shaped).
|
||||
expect(item.parentId, '27511f928761c3f5c080d43d6799ea09');
|
||||
expect(item.parentTitle, 'Live at Testhalle');
|
||||
expect(item.grandparentId, 'a603621309dc866c91b6c5fe10cee64d');
|
||||
expect(item.grandparentTitle, 'The Synth Pops');
|
||||
// Derived music getters.
|
||||
expect(item.trackNumber, 1);
|
||||
expect(item.discNumber, 1);
|
||||
expect(item.albumTitle, 'Live at Testhalle');
|
||||
expect(item.albumArtistTitle, 'The Synth Pops');
|
||||
// Artists == [AlbumArtist] → no per-track performer override.
|
||||
expect(item.originalTitle, isNull);
|
||||
expect(item.trackArtistTitle, 'The Synth Pops');
|
||||
// Embedded art wins over the album fallback.
|
||||
expect(
|
||||
item.thumbPath,
|
||||
'/Items/425f9ab168792a3be733d169b770853f/Images/Primary?tag=1ed1281fd45ff9b8b5ad62b6f6a34d17',
|
||||
);
|
||||
expect(item.durationMs, 12000);
|
||||
});
|
||||
|
||||
test('compilation track maps differing Artists into originalTitle', () {
|
||||
final json = _audioJson()
|
||||
..['Artists'] = ['Artist One', 'Artist Two']
|
||||
..['AlbumArtist'] = 'Various Artists'
|
||||
..['AlbumArtists'] = [
|
||||
{'Name': 'Various Artists', 'Id': 'va-1'},
|
||||
];
|
||||
|
||||
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
|
||||
|
||||
expect(item.originalTitle, 'Artist One, Artist Two');
|
||||
expect(item.trackArtistTitle, 'Artist One, Artist Two');
|
||||
expect(item.albumArtistTitle, 'Various Artists');
|
||||
expect(item.grandparentId, 'va-1');
|
||||
});
|
||||
|
||||
test('track without embedded art falls back to the album primary image', () {
|
||||
final json = _audioJson()..['ImageTags'] = <String, dynamic>{};
|
||||
|
||||
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
|
||||
|
||||
expect(
|
||||
item.thumbPath,
|
||||
'/Items/27511f928761c3f5c080d43d6799ea09/Images/Primary?tag=233dccb8ad84d8ac473dbffb86c35e6c',
|
||||
);
|
||||
});
|
||||
|
||||
test('track without embedded art or album image tag keeps a null thumb', () {
|
||||
final json = _audioJson()
|
||||
..['ImageTags'] = <String, dynamic>{}
|
||||
..remove('AlbumPrimaryImageTag');
|
||||
|
||||
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
|
||||
|
||||
expect(item.thumbPath, isNull);
|
||||
});
|
||||
|
||||
test('maps a MusicAlbum with artist hierarchy and track counts', () {
|
||||
final item = JellyfinMappers.mediaItem(_albumJson(), serverId: ServerId(_serverId), absolutizer: null)!;
|
||||
|
||||
expect(item.kind, MediaKind.album);
|
||||
expect(item.title, 'Live at Testhalle');
|
||||
expect(item.albumTitle, 'Live at Testhalle');
|
||||
expect(item.year, 2022);
|
||||
// An album's parent is its artist (Plex parity — Jellyfin albums link
|
||||
// artists via tags, not ParentId), so navigation/getters can rely on
|
||||
// parentId across backends.
|
||||
expect(item.parentId, 'a603621309dc866c91b6c5fe10cee64d');
|
||||
expect(item.parentTitle, 'The Synth Pops');
|
||||
expect(item.albumArtistTitle, 'The Synth Pops');
|
||||
expect(item.grandparentId, isNull);
|
||||
expect(item.grandparentTitle, isNull);
|
||||
expect(item.leafCount, 8);
|
||||
// Artists mirrors AlbumArtist on album rows — no override.
|
||||
expect(item.originalTitle, isNull);
|
||||
expect(
|
||||
item.thumbPath,
|
||||
'/Items/27511f928761c3f5c080d43d6799ea09/Images/Primary?tag=233dccb8ad84d8ac473dbffb86c35e6c',
|
||||
);
|
||||
});
|
||||
|
||||
test('episode hierarchy fields keep priority over music fallbacks', () {
|
||||
// Defensive: Season*/Series* must always win should a row ever carry
|
||||
// both (the music fallbacks are appended with `??`).
|
||||
final json = _audioJson()
|
||||
..['Type'] = 'Episode'
|
||||
..['SeasonId'] = 'season-1'
|
||||
..['SeasonName'] = 'Season 1'
|
||||
..['SeriesId'] = 'series-1'
|
||||
..['SeriesName'] = 'Show';
|
||||
|
||||
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
|
||||
|
||||
expect(item.parentId, 'season-1');
|
||||
expect(item.parentTitle, 'Season 1');
|
||||
expect(item.grandparentId, 'series-1');
|
||||
expect(item.grandparentTitle, 'Show');
|
||||
});
|
||||
});
|
||||
|
||||
group('JellyfinClient.fetchLyrics', () {
|
||||
JellyfinClient clientWith(Future<http.Response> Function(http.Request) handler) =>
|
||||
JellyfinClient.forTesting(connection: _conn(), httpClient: MockClient(handler));
|
||||
|
||||
final track = JellyfinMappers.mediaItem(_audioJson(), serverId: ServerId(_serverId), absolutizer: null)!;
|
||||
|
||||
test('parses tick offsets to ms and infers synced from Start presence', () async {
|
||||
final client = clientWith((request) async {
|
||||
expect(request.url.path, '/Audio/425f9ab168792a3be733d169b770853f/Lyrics');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Metadata': <String, dynamic>{},
|
||||
'Lyrics': [
|
||||
{'Text': 'First light breaking over the test grid', 'Start': 5000000},
|
||||
{'Text': 'Synthetic voices humming in time', 'Start': 40000000},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final lyrics = await client.fetchLyrics(track);
|
||||
|
||||
expect(lyrics, isNotNull);
|
||||
expect(lyrics!.synced, isTrue);
|
||||
expect(lyrics.lines, hasLength(2));
|
||||
expect(lyrics.lines.first.text, 'First light breaking over the test grid');
|
||||
expect(lyrics.lines.first.startMs, 500);
|
||||
expect(lyrics.lines[1].startMs, 4000);
|
||||
});
|
||||
|
||||
test('treats missing Start offsets as unsynced plain text', () async {
|
||||
final client = clientWith((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Lyrics': [
|
||||
{'Text': 'Line one'},
|
||||
{'Text': 'Line two'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final lyrics = await client.fetchLyrics(track);
|
||||
|
||||
expect(lyrics, isNotNull);
|
||||
expect(lyrics!.synced, isFalse);
|
||||
expect(lyrics.lines.map((l) => l.startMs), everyElement(isNull));
|
||||
});
|
||||
|
||||
test('returns null on 404 (track has no lyrics)', () async {
|
||||
final client = clientWith((request) async => http.Response('Not Found', 404));
|
||||
addTearDown(client.close);
|
||||
|
||||
expect(await client.fetchLyrics(track), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/models/audio_quality_preset.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: '1',
|
||||
),
|
||||
serverId: ServerId('server-id'),
|
||||
httpClient: MockClient(handler),
|
||||
);
|
||||
}
|
||||
|
||||
test('music transcode params cap bitrate and carry the musicProfile target', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final params = client.buildMusicTranscodeParamsForTesting(
|
||||
ratingKey: '9669',
|
||||
mediaIndex: 0,
|
||||
preset: AudioQualityPreset.medium,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
);
|
||||
|
||||
expect(params['hasMDE'], '1');
|
||||
expect(params['path'], '/library/metadata/9669');
|
||||
expect(params['mediaIndex'], '0');
|
||||
expect(params['partIndex'], '0');
|
||||
expect(params['protocol'], 'http');
|
||||
expect(params['directPlay'], '0');
|
||||
expect(params['directStream'], '0');
|
||||
expect(params['musicBitrate'], '192');
|
||||
expect(params['session'], 'transcode-id');
|
||||
expect(params['X-Plex-Session-Identifier'], 'session-id');
|
||||
expect(
|
||||
params['X-Plex-Client-Profile-Extra'],
|
||||
'add-transcode-target(type=musicProfile&context=streaming'
|
||||
'&protocol=http&container=mp3&audioCodec=mp3)',
|
||||
);
|
||||
});
|
||||
|
||||
test('music transcode params carry no video/subtitle params', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final params = client.buildMusicTranscodeParamsForTesting(
|
||||
ratingKey: '9669',
|
||||
mediaIndex: 0,
|
||||
preset: AudioQualityPreset.high,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
);
|
||||
|
||||
expect(params['musicBitrate'], '320');
|
||||
for (final videoOnly in ['subtitles', 'subtitleStreamID', 'advancedSubtitles', 'copyts', 'maxVideoBitrate']) {
|
||||
expect(params.containsKey(videoOnly), isFalse, reason: '$videoOnly is video-only');
|
||||
}
|
||||
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('videoProfile')));
|
||||
});
|
||||
|
||||
test('original preset omits musicBitrate', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final params = client.buildMusicTranscodeParamsForTesting(
|
||||
ratingKey: '9669',
|
||||
mediaIndex: 0,
|
||||
preset: AudioQualityPreset.original,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
);
|
||||
|
||||
expect(params.containsKey('musicBitrate'), isFalse);
|
||||
});
|
||||
|
||||
test('music start path uses the mp3 start endpoint without token', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final params = client.buildMusicTranscodeParamsForTesting(
|
||||
ratingKey: '9669',
|
||||
mediaIndex: 1,
|
||||
partIndex: 2,
|
||||
preset: AudioQualityPreset.medium,
|
||||
sessionIdentifier: 'session-id',
|
||||
transcodeSessionId: 'transcode-id',
|
||||
);
|
||||
|
||||
final startPath = client.buildTranscodeStartPathFromParamsForTesting(
|
||||
params,
|
||||
endpoint: '/music/:/transcode/universal/start.mp3',
|
||||
);
|
||||
|
||||
expect(startPath, startsWith('/music/:/transcode/universal/start.mp3?'));
|
||||
expect(startPath, contains('musicBitrate=192'));
|
||||
expect(startPath, contains('mediaIndex=1'));
|
||||
expect(startPath, contains('partIndex=2'));
|
||||
// Profile-extra parens/ampersands must be percent-encoded on the wire.
|
||||
expect(
|
||||
startPath,
|
||||
contains(
|
||||
'X-Plex-Client-Profile-Extra=add-transcode-target%28type%3DmusicProfile%26context%3Dstreaming'
|
||||
'%26protocol%3Dhttp%26container%3Dmp3%26audioCodec%3Dmp3%29',
|
||||
),
|
||||
);
|
||||
expect(startPath, isNot(contains('X-Plex-Token')));
|
||||
});
|
||||
}
|
||||
@@ -39,7 +39,7 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
test('search defaults to 100 movie and TV candidates', () async {
|
||||
test('search defaults to 100 movie, TV, and music candidates', () async {
|
||||
final captured = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
captured.add(request.url);
|
||||
@@ -66,6 +66,6 @@ void main() {
|
||||
expect(captured.single.path, '/library/search');
|
||||
expect(captured.single.queryParameters['limit'], '100');
|
||||
expect(captured.single.queryParameters['X-Plex-Container-Size'], '100');
|
||||
expect(captured.single.queryParameters['searchTypes'], 'movies,tv');
|
||||
expect(captured.single.queryParameters['searchTypes'], 'movies,tv,music');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/utils/lrc_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('parseLrc', () {
|
||||
test('parses synced lines ordered by tick', () {
|
||||
final lyrics = parseLrc('[00:20.00]Second line\n[00:10.00]First line\n[01:05.00]Third line');
|
||||
|
||||
expect(lyrics, isNotNull);
|
||||
expect(lyrics!.synced, isTrue);
|
||||
expect(lyrics.lines.map((l) => l.text), ['First line', 'Second line', 'Third line']);
|
||||
expect(lyrics.lines.map((l) => l.startMs), [10000, 20000, 65000]);
|
||||
});
|
||||
|
||||
test('emits a multi-timestamp line once per timestamp', () {
|
||||
final lyrics = parseLrc('[00:05.00]Verse\n[00:10.00][00:30.00]Chorus');
|
||||
|
||||
expect(lyrics!.lines.map((l) => l.text), ['Verse', 'Chorus', 'Chorus']);
|
||||
expect(lyrics.lines.map((l) => l.startMs), [5000, 10000, 30000]);
|
||||
});
|
||||
|
||||
test('applies the offset tag and clamps below zero', () {
|
||||
final lyrics = parseLrc('[offset:+500]\n[00:01.00]Late start\n[00:00.20]Clamped');
|
||||
|
||||
// offset is subtracted: 1000 - 500 = 500; 200 - 500 clamps to 0.
|
||||
expect(lyrics!.lines.map((l) => l.startMs), [0, 500]);
|
||||
expect(lyrics.lines.map((l) => l.text), ['Clamped', 'Late start']);
|
||||
});
|
||||
|
||||
test('scales fractional part by digit count', () {
|
||||
final lyrics = parseLrc('[00:01.5]One digit\n[00:02.50]Two digits\n[00:03.500]Three digits');
|
||||
|
||||
expect(lyrics!.lines.map((l) => l.startMs), [1500, 2500, 3500]);
|
||||
});
|
||||
|
||||
test('skips metadata id tags', () {
|
||||
final lyrics = parseLrc('[ar:The Synth Pops]\n[ti:Dawn]\n[al:Album]\n[00:01.00]Actual line');
|
||||
|
||||
expect(lyrics!.synced, isTrue);
|
||||
expect(lyrics.lines, hasLength(1));
|
||||
expect(lyrics.lines.single.text, 'Actual line');
|
||||
});
|
||||
|
||||
test('falls back to unsynced plain text when no timestamps exist', () {
|
||||
final lyrics = parseLrc('[ar:Artist]\nJust some words\nAnother line');
|
||||
|
||||
expect(lyrics, isNotNull);
|
||||
expect(lyrics!.synced, isFalse);
|
||||
expect(lyrics.lines.map((l) => l.text), ['Just some words', 'Another line']);
|
||||
expect(lyrics.lines.every((l) => l.startMs == null), isTrue);
|
||||
});
|
||||
|
||||
test('returns null for blank input', () {
|
||||
expect(parseLrc(''), isNull);
|
||||
expect(parseLrc(' \n\n '), isNull);
|
||||
expect(parseLrc('[ar:Only tags]'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user