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:
edde746
2026-07-05 15:55:52 +02:00
parent c87a9060f9
commit fff559da06
27 changed files with 1487 additions and 125 deletions
+3
View File
@@ -700,6 +700,9 @@
"nextUpIn": "Next Up in ${library}", "nextUpIn": "Next Up in ${library}",
"recentlyAdded": "Recently Added", "recentlyAdded": "Recently Added",
"recentlyAddedIn": "Recently Added in ${library}", "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}", "playEpisode": "S${season}E${episode}",
"overview": "Overview", "overview": "Overview",
"cast": "Cast", "cast": "Cast",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 16 /// Locales: 16
/// Strings: 20863 (1303 per locale) /// Strings: 20866 (1304 per locale)
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+14 -2
View File
@@ -2155,6 +2155,15 @@ class TranslationsDiscoverEn {
/// en: 'Recently Added in ${library}' /// en: 'Recently Added in ${library}'
String recentlyAddedIn({required Object library}) => '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}' /// en: 'S${season}E${episode}'
String playEpisode({required Object season, required Object episode}) => '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.nextUpIn' => ({required Object library}) => 'Next Up in ${library}',
'discover.recentlyAdded' => 'Recently Added', 'discover.recentlyAdded' => 'Recently Added',
'discover.recentlyAddedIn' => ({required Object library}) => 'Recently Added in ${library}', '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.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}',
'discover.overview' => 'Overview', 'discover.overview' => 'Overview',
'discover.cast' => 'Cast', 'discover.cast' => 'Cast',
@@ -5632,11 +5644,11 @@ extension on Translations {
'shaders.title' => 'Shaders', 'shaders.title' => 'Shaders',
'shaders.noShaderDescription' => 'No video enhancement', 'shaders.noShaderDescription' => 'No video enhancement',
'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video', 'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video',
_ => null,
} ?? switch (path) {
'shaders.artcnnVariantNeutral' => 'Neutral', 'shaders.artcnnVariantNeutral' => 'Neutral',
'shaders.artcnnVariantDenoise' => 'Denoise', 'shaders.artcnnVariantDenoise' => 'Denoise',
'shaders.artcnnVariantDenoiseSharpen' => 'Denoise + Sharpen', 'shaders.artcnnVariantDenoiseSharpen' => 'Denoise + Sharpen',
_ => null,
} ?? switch (path) {
'shaders.qualityFast' => 'Fast', 'shaders.qualityFast' => 'Fast',
'shaders.qualityHQ' => 'High Quality', 'shaders.qualityHQ' => 'High Quality',
'shaders.mode' => 'Mode', 'shaders.mode' => 'Mode',
+23
View File
@@ -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;
}
+28
View File
@@ -464,6 +464,34 @@ sealed class MediaItem with _$MediaItem {
return null; 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. /// Plex-only edition label. Jellyfin returns null.
String? get editionTitle => null; String? get editionTitle => null;
+30
View File
@@ -12,6 +12,7 @@ import 'library_filter_result.dart';
import 'library_first_character.dart'; import 'library_first_character.dart';
import 'library_query.dart'; import 'library_query.dart';
import 'live_tv_support.dart'; import 'live_tv_support.dart';
import 'lyrics.dart';
import 'media_backend.dart'; import 'media_backend.dart';
import 'media_file_info.dart'; import 'media_file_info.dart';
import 'media_hub.dart'; import 'media_hub.dart';
@@ -235,6 +236,35 @@ abstract class MediaServerClient {
/// series" via the null vs `[]` distinction. /// series" via the null vs `[]` distinction.
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId); 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. /// Free-text search across the user's libraries.
Future<List<MediaItem>> searchItems(String query, {int limit = 100}); Future<List<MediaItem>> searchItems(String query, {int limit = 100});
+31
View File
@@ -109,6 +109,22 @@ class ServerCapabilities {
/// `/Items?ParentId=...&Recursive=false` queries. /// `/Items?ParentId=...&Recursive=false` queries.
final bool folderGrouping; 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({ const ServerCapabilities({
this.serverSidePlayQueue = false, this.serverSidePlayQueue = false,
this.serverSidePlaylists = false, this.serverSidePlaylists = false,
@@ -130,6 +146,9 @@ class ServerCapabilities {
this.alphaBar = AlphaBarMode.none, this.alphaBar = AlphaBarMode.none,
this.scrubThumbnails = false, this.scrubThumbnails = false,
this.folderGrouping = false, this.folderGrouping = false,
this.lyrics = false,
this.instantMix = false,
this.audioTranscoding = false,
}); });
/// Defaults for a fully-featured Plex server. /// Defaults for a fully-featured Plex server.
@@ -154,6 +173,9 @@ class ServerCapabilities {
alphaBar: AlphaBarMode.scrollSnap, alphaBar: AlphaBarMode.scrollSnap,
scrubThumbnails: true, scrubThumbnails: true,
folderGrouping: true, folderGrouping: true,
lyrics: true,
instantMix: true,
audioTranscoding: true,
); );
/// Defaults for a Jellyfin server. /// Defaults for a Jellyfin server.
@@ -185,6 +207,9 @@ class ServerCapabilities {
alphaBar: AlphaBarMode.nameStartsWithFilter, alphaBar: AlphaBarMode.nameStartsWithFilter,
scrubThumbnails: true, scrubThumbnails: true,
folderGrouping: true, folderGrouping: true,
lyrics: true,
instantMix: true,
audioTranscoding: true,
); );
ServerCapabilities copyWith({ ServerCapabilities copyWith({
@@ -208,6 +233,9 @@ class ServerCapabilities {
AlphaBarMode? alphaBar, AlphaBarMode? alphaBar,
bool? scrubThumbnails, bool? scrubThumbnails,
bool? folderGrouping, bool? folderGrouping,
bool? lyrics,
bool? instantMix,
bool? audioTranscoding,
}) { }) {
return ServerCapabilities( return ServerCapabilities(
serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue, serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue,
@@ -230,6 +258,9 @@ class ServerCapabilities {
alphaBar: alphaBar ?? this.alphaBar, alphaBar: alphaBar ?? this.alphaBar,
scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails, scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails,
folderGrouping: folderGrouping ?? this.folderGrouping, folderGrouping: folderGrouping ?? this.folderGrouping,
lyrics: lyrics ?? this.lyrics,
instantMix: instantMix ?? this.instantMix,
audioTranscoding: audioTranscoding ?? this.audioTranscoding,
); );
} }
} }
+35
View File
@@ -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);
}
+4
View File
@@ -15,6 +15,7 @@ import 'file_info_parser.dart';
import 'library_query_translator.dart'; import 'library_query_translator.dart';
import '../media/media_filter.dart'; import '../media/media_filter.dart';
import '../media/live_tv_support.dart'; import '../media/live_tv_support.dart';
import '../media/lyrics.dart';
import '../media/media_backend.dart'; import '../media/media_backend.dart';
import '../media/media_file_info.dart'; import '../media/media_file_info.dart';
import '../media/media_hub.dart'; import '../media/media_hub.dart';
@@ -26,6 +27,7 @@ import '../media/ids.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart'; import '../media/playback_report_metadata.dart';
import '../media/server_capabilities.dart'; import '../media/server_capabilities.dart';
import '../models/audio_quality_preset.dart';
import '../models/jellyfin/jellyfin_user_profile.dart'; import '../models/jellyfin/jellyfin_user_profile.dart';
import '../models/livetv_capture_buffer.dart'; import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart'; import '../models/livetv_channel.dart';
@@ -69,6 +71,7 @@ import 'scrub_preview_source.dart';
import '../mpv/mpv.dart'; import '../mpv/mpv.dart';
part 'jellyfin_client/parts/browse.dart'; part 'jellyfin_client/parts/browse.dart';
part 'jellyfin_client/parts/music.dart';
part 'jellyfin_client/parts/playback.dart'; part 'jellyfin_client/parts/playback.dart';
part 'jellyfin_client/parts/watch_state.dart'; part 'jellyfin_client/parts/watch_state.dart';
part 'jellyfin_client/parts/playlists.dart'; part 'jellyfin_client/parts/playlists.dart';
@@ -88,6 +91,7 @@ class JellyfinClient
with with
MediaServerCacheMixin, MediaServerCacheMixin,
_JellyfinBrowseMethods, _JellyfinBrowseMethods,
_JellyfinMusicMethods,
_JellyfinPlaybackMethods, _JellyfinPlaybackMethods,
_JellyfinWatchStateMethods, _JellyfinWatchStateMethods,
_JellyfinPlaylistMethods, _JellyfinPlaylistMethods,
+144 -16
View File
@@ -90,7 +90,7 @@ const _continueWatchingSeriesLookback = 200;
const _childrenPageSize = 500; const _childrenPageSize = 500;
const _pagedListPageSize = 200; const _pagedListPageSize = 200;
const _playableDescendantTypes = 'Movie,Episode'; const _playableDescendantTypes = 'Movie,Episode,Audio';
const _playableFolderDescendantTypes = 'Movie,Episode,Video,MusicVideo'; const _playableFolderDescendantTypes = 'Movie,Episode,Video,MusicVideo';
const _episodeOrderQueryParameters = { const _episodeOrderQueryParameters = {
'SortBy': 'ParentIndexNumber,IndexNumber,SortName', 'SortBy': 'ParentIndexNumber,IndexNumber,SortName',
@@ -179,12 +179,35 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
); );
final params = translator.toQueryParameters(query); 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); throwIfHttpError(response);
final data = response.data; final data = response.data;
final items = _itemsArray(data); final items = _itemsArray(data);
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null; 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 ? rawTotal
: _fallbackPageTotal(offset: query.offset, itemCount: items.length, requestedSize: query.limit); : _fallbackPageTotal(offset: query.offset, itemCount: items.length, requestedSize: query.limit);
return LibraryPage<MediaItem>(items: _mapItems(items), totalCount: total, offset: query.offset); return LibraryPage<MediaItem>(items: _mapItems(items), totalCount: total, offset: query.offset);
@@ -844,10 +867,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
return _mapItems(allRaw); return _mapItems(allRaw);
} }
/// All directly-playable descendants of [parentId] (Movies + Episodes), /// All directly-playable descendants of [parentId] (Movies + Episodes +
/// recursively expanded. Used by the playback launcher so a collection /// Audio tracks), recursively expanded. Used by the playback launcher so a
/// containing a Series plays its episodes instead of the unplayable /// collection containing a Series plays its episodes instead of the
/// Series entry, and a playlist mixing both comes through the same path. /// 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] /// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems]
/// since those preserve the container shape (Series rows, PlaylistItemId). /// since those preserve the container shape (Series rows, PlaylistItemId).
/// ///
@@ -978,20 +1002,28 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
@override @override
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async { Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
final response = await _http.get( // Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=`
'/Items', // only matches folder-derived MusicArtist rows (under folder names), so
queryParameters: { // 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, 'userId': connection.userId,
'SearchTerm': query, 'SearchTerm': query,
'Recursive': 'true', 'Recursive': 'true',
'Limit': limit.toString(), 'Limit': limit.toString(),
'IncludeItemTypes': 'Movie,Series,Episode', 'IncludeItemTypes': 'Movie,Series,Episode,MusicAlbum,Audio',
'Fields': _browseFields, 'Fields': _browseFields,
...jellyfinImageQueryParameters, ...jellyfinImageQueryParameters,
}, }),
); _safeFetchItemsArray('/Artists', {
throwIfHttpError(response); 'userId': connection.userId,
return _mapItems(_itemsArray(response.data)); 'searchTerm': query,
'Limit': limit.toString(),
...jellyfinImageQueryParameters,
}),
]);
return _mapItems([...results.first, ...results[1]]);
} }
@override @override
@@ -1192,6 +1224,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
...jellyfinImageQueryParameters, ...jellyfinImageQueryParameters,
}, retry: _libraryHubRetry); }, 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) { if (!includePlaybackHubs) {
final latest = await latestFuture; final latest = await latestFuture;
return [ return [
@@ -1268,11 +1309,76 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
].where((h) => h.items.isNotEmpty).toList(); ].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 /// Re-run the synthetic hub query without the preview limit so the
/// hub-detail screen can render the full list. Branches on the /// hub-detail screen can render the full list. Branches on the
/// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]: /// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]:
/// `home.recent` / `library.{id}.recent` → Latest, `*.continue` → Resume, /// `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 @override
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) async { Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) async {
try { try {
@@ -1356,6 +1462,28 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
requestedSize: pageSize, requestedSize: pageSize,
abort: abort, 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: default:
return LibraryPage<MediaItem>(items: const [], totalCount: 0, offset: offset); return LibraryPage<MediaItem>(items: const [], totalCount: 0, offset: offset);
} }
@@ -16,6 +16,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
String? liveStreamId, String? liveStreamId,
int? audioStreamIndex, int? audioStreamIndex,
}); });
String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId});
Future<Map<String, dynamic>?> getPlaybackInfo( Future<Map<String, dynamic>?> getPlaybackInfo(
String itemId, { String itemId, {
int? maxStreamingBitrate = 100_000_000, int? maxStreamingBitrate = 100_000_000,
@@ -30,6 +31,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
bool? enableTranscoding, bool? enableTranscoding,
bool? allowVideoStreamCopy, bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy, bool? allowAudioStreamCopy,
bool audioProfile,
}); });
String _withApiKey(String urlOrPath); String _withApiKey(String urlOrPath);
@@ -55,19 +57,36 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
@override @override
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async { 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); final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex, sourceId: mediaSourceId);
if (bundle == null) return buildDirectStreamUrl(item.id); if (bundle == null) {
return buildDirectStreamUrl( return isTrack ? buildAudioDirectStreamUrl(item.id) : buildDirectStreamUrl(item.id);
item.id, }
container: bundle.container, final container = bundle.container;
mediaSourceId: bundle.pinnedSourceIdForItem(item.id), final pinnedSourceId = bundle.pinnedSourceIdForItem(item.id);
); return isTrack
? buildAudioDirectStreamUrl(item.id, container: container, mediaSourceId: pinnedSourceId)
: buildDirectStreamUrl(item.id, container: container, mediaSourceId: pinnedSourceId);
} }
@override @override
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0}) async { Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0}) async {
final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex);
final selectedSourceId = bundle?.selectedSourceId; 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` // Direct-stream the selected original file. Jellyfin's `Static=true`
// skips the transcoder so the byte-for-byte source lands on disk. // skips the transcoder so the byte-for-byte source lands on disk.
final videoUrl = buildDirectStreamUrl( 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; var isTranscoding = false;
TranscodeFallbackReason? fallbackReason; 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 preset = options.qualityPreset;
final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal;
final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo); 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 resumeOffsetMs = metadata.viewOffsetMs;
final int? transcodeStartTimeTicks = !preset.isOriginal && resumeOffsetMs != null && resumeOffsetMs > 0 final int? transcodeStartTimeTicks = !wantsOriginal && resumeOffsetMs != null && resumeOffsetMs > 0
? msToJellyfinTicks(resumeOffsetMs) ? msToJellyfinTicks(resumeOffsetMs)
: null; : null;
final negotiation = await getPlaybackInfo( final negotiation = await getPlaybackInfo(
@@ -172,9 +185,10 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
mediaSourceId: bundle.selectedSourceId, mediaSourceId: bundle.selectedSourceId,
startTimeTicks: transcodeStartTimeTicks, startTimeTicks: transcodeStartTimeTicks,
audioStreamIndex: requestedAudioStreamId, audioStreamIndex: requestedAudioStreamId,
audioProfile: isTrack,
); );
if (negotiation == null) { if (negotiation == null) {
if (!preset.isOriginal) { if (!wantsOriginal) {
fallbackReason = TranscodeFallbackReason.decisionFailed; fallbackReason = TranscodeFallbackReason.decisionFailed;
} }
} else { } else {
@@ -200,7 +214,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
final transcodingUrl = chosenSource['TranscodingUrl']; final transcodingUrl = chosenSource['TranscodingUrl'];
final directStreamUrl = chosenSource['DirectStreamUrl']; 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, // TranscodingUrl is server-relative and already encodes container,
// codecs, MediaSourceId, and PlaySessionId; we just append the // codecs, MediaSourceId, and PlaySessionId; we just append the
// api_key for auth. // api_key for auth.
@@ -214,25 +228,31 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
videoUrl = _withApiKey(directStreamUrl); videoUrl = _withApiKey(directStreamUrl);
playMethod = 'DirectStream'; playMethod = 'DirectStream';
} else { } else {
if (!preset.isOriginal) { if (!wantsOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly; fallbackReason = TranscodeFallbackReason.directPlayOnly;
} }
} }
} else if (!preset.isOriginal) { } else if (!wantsOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly; fallbackReason = TranscodeFallbackReason.directPlayOnly;
} }
} }
final effectiveAudioStreamId = _resolveJellyfinAudioStreamId(requestedAudioStreamId, mediaInfo); final effectiveAudioStreamId = _resolveJellyfinAudioStreamId(requestedAudioStreamId, mediaInfo);
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId); mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
final externalSubtitles = _buildExternalSubtitles( // Tracks have no subtitle streams to assemble (a `Lyric` stream may be
metadata.id, // present, but lyrics flow through fetchLyrics, not the subtitle path).
effectiveSourceId, final externalSubtitles = isTrack
mediaInfo, ? const <SubtitleTrack>[]
includeExternalDelivery: includeExternalSubtitleDelivery, : _buildExternalSubtitles(
); metadata.id,
effectiveSourceId,
mediaInfo,
includeExternalDelivery: includeExternalSubtitleDelivery,
);
final pinnedSourceId = bundle.pinnedSourceIdForItem(metadata.id); 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( return PlaybackInitializationResult(
availableVersions: bundle.availableVersions, 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 /// Trickplay sprite-sheet URL. [width] picks one of the resolutions
/// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based /// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based
/// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails). /// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails).
@@ -469,6 +504,9 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
/// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams /// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams
/// to pick for the transcode profile (Jellyfin's negotiation factors them in /// to pick for the transcode profile (Jellyfin's negotiation factors them in
/// when picking codec compatibility). /// 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( Future<Map<String, dynamic>?> getPlaybackInfo(
String itemId, { String itemId, {
int? maxStreamingBitrate = 100_000_000, int? maxStreamingBitrate = 100_000_000,
@@ -483,6 +521,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
bool? enableTranscoding, bool? enableTranscoding,
bool? allowVideoStreamCopy, bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy, bool? allowAudioStreamCopy,
bool audioProfile = false,
}) async { }) async {
try { try {
final query = <String, String>{ 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 // ahead of H.264 so a server that has "Allow encoding in HEVC
// format" enabled will actually emit HEVC instead of falling // format" enabled will actually emit HEVC instead of falling
// back to H.264. // back to H.264.
'TranscodingProfiles': const <Map<String, Object?>>[ 'TranscodingProfiles': <Map<String, Object?>>[
{ const {
'Type': 'Video', 'Type': 'Video',
'Container': 'ts', 'Container': 'ts',
'Protocol': 'hls', 'Protocol': 'hls',
'VideoCodec': 'hevc,h264', 'VideoCodec': 'hevc,h264',
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus', '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 // Declaring HEVC in DirectPlayProfile.VideoCodec stops the server
// from forcing a transcode for HEVC sources whose container we // from forcing a transcode for HEVC sources whose container we
// already accept — mpv decodes HEVC natively on every platform // already accept — mpv decodes HEVC natively on every platform
// we ship. // we ship.
'DirectPlayProfiles': const <Map<String, Object?>>[ 'DirectPlayProfiles': <Map<String, Object?>>[
{ const {
'Type': 'Video', 'Type': 'Video',
'Container': 'mp4,mkv,m4v,webm,mov,ts', 'Container': 'mp4,mkv,m4v,webm,mov,ts',
'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video', 'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video',
'AudioCodec': 'aac,mp3,mp2,ac3,eac3,flac,opus,vorbis,dts', '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?>>[ 'SubtitleProfiles': const <Map<String, Object?>>[
{'Format': 'srt', 'Method': 'External'}, {'Format': 'srt', 'Method': 'External'},
@@ -144,13 +144,17 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
@override @override
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async { 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( final response = await _http.post(
'/Playlists', '/Playlists',
queryParameters: { queryParameters: {
'Name': title, 'Name': title,
'Ids': items.map((i) => i.id).join(','), 'Ids': items.map((i) => i.id).join(','),
'UserId': connection.userId, 'UserId': connection.userId,
'MediaType': 'Video', 'MediaType': isMusic ? 'Audio' : 'Video',
}, },
); );
throwIfHttpError(response); throwIfHttpError(response);
+55 -6
View File
@@ -164,21 +164,38 @@ class JellyfinMappers {
titleSort: item['SortName'] as String?, titleSort: item['SortName'] as String?,
summary: item['Overview'] as String?, summary: item['Overview'] as String?,
tagline: _firstString(item['Taglines']), 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']), studio: _firstStudioName(item['Studios']),
year: item['ProductionYear'] as int?, year: item['ProductionYear'] as int?,
originallyAvailableAt: jellyfinIsoToYmd(item['PremiereDate'] as String?), originallyAvailableAt: jellyfinIsoToYmd(item['PremiereDate'] as String?),
contentRating: item['OfficialRating'] as String?, contentRating: item['OfficialRating'] as String?,
parentId: item['SeasonId'] as String? ?? item['ParentId'] as String?, // Music mirrors the episode hierarchy, matching Plex's parent chain:
parentTitle: item['SeasonName'] as String?, // 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'), parentThumbPath: _imagePath(item, 'SeasonId', 'SeasonPrimaryImageTag', 'Primary'),
parentIndex: item['ParentIndexNumber'] as int?, parentIndex: item['ParentIndexNumber'] as int?,
index: item['IndexNumber'] as int?, index: item['IndexNumber'] as int?,
grandparentId: item['SeriesId'] as String?, grandparentId: item['SeriesId'] as String? ?? (kind == MediaKind.track ? _firstAlbumArtistId(item) : null),
grandparentTitle: item['SeriesName'] as String?, grandparentTitle:
item['SeriesName'] as String? ?? (kind == MediaKind.track ? item['AlbumArtist'] as String? : null),
grandparentThumbPath: _seriesPrimaryImage(item), grandparentThumbPath: _seriesPrimaryImage(item),
grandparentArtPath: _parentBackdropImage(item) ?? _seriesBackdropImage(item), grandparentArtPath: _parentBackdropImage(item) ?? _seriesBackdropImage(item),
thumbPath: _selfImagePath(id, item, 'Primary'), thumbPath: _selfImagePath(id, item, 'Primary') ?? _albumPrimaryImage(item),
artPath: _selfImagePath(id, item, 'Backdrop'), artPath: _selfImagePath(id, item, 'Backdrop'),
// Episodes/seasons don't carry their own logo — Jellyfin exposes the // Episodes/seasons don't carry their own logo — Jellyfin exposes the
// parent's logo via ParentLogoItemId/ParentLogoImageTag, which is // parent's logo via ParentLogoItemId/ParentLogoImageTag, which is
@@ -472,6 +489,38 @@ class JellyfinMappers {
return _itemImagePath(id, type, tag: tag); 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) { static String? _seriesPrimaryImage(Map<String, dynamic> item) {
final seriesId = item['SeriesId'] as String?; final seriesId = item['SeriesId'] as String?;
if (seriesId == null) return null; if (seriesId == null) return null;
+5 -1
View File
@@ -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({ String buildJellyfinDirectStreamUrl({
required String baseUrl, required String baseUrl,
required String accessToken, required String accessToken,
required String deviceId, required String deviceId,
required String itemId, required String itemId,
String mediaSegment = 'Videos',
String? container, String? container,
String? mediaSourceId, String? mediaSourceId,
String? playSessionId, String? playSessionId,
@@ -20,7 +24,7 @@ String buildJellyfinDirectStreamUrl({
'AudioStreamIndex': ?audioStreamIndex?.toString(), 'AudioStreamIndex': ?audioStreamIndex?.toString(),
}; };
final encodedItem = Uri.encodeComponent(itemId); final encodedItem = Uri.encodeComponent(itemId);
return '$baseUrl/Videos/$encodedItem/stream?${_encodeQuery(params)}'; return '$baseUrl/$mediaSegment/$encodedItem/stream?${_encodeQuery(params)}';
} }
String buildJellyfinTrickplayTileUrl({ String buildJellyfinTrickplayTileUrl({
@@ -1,6 +1,7 @@
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_source_info.dart'; import '../media/media_source_info.dart';
import '../media/media_version.dart'; import '../media/media_version.dart';
import '../models/audio_quality_preset.dart';
import '../models/transcode_quality_preset.dart'; import '../models/transcode_quality_preset.dart';
import '../mpv/mpv.dart'; import '../mpv/mpv.dart';
@@ -28,6 +29,12 @@ class PlaybackInitializationOptions {
/// server to transcode when supported. /// server to transcode when supported.
final TranscodeQualityPreset qualityPreset; 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 /// Audio stream id forwarded to the transcoder. `null` means "let the
/// server pick". /// server pick".
final int? selectedAudioStreamId; final int? selectedAudioStreamId;
@@ -45,6 +52,7 @@ class PlaybackInitializationOptions {
this.selectedMediaSourceId, this.selectedMediaSourceId,
this.preferredVersionSignature, this.preferredVersionSignature,
this.qualityPreset = TranscodeQualityPreset.original, this.qualityPreset = TranscodeQualityPreset.original,
this.audioQualityPreset,
this.selectedAudioStreamId, this.selectedAudioStreamId,
this.sessionIdentifier, this.sessionIdentifier,
this.transcodeSessionId, this.transcodeSessionId,
+357 -70
View File
@@ -3,12 +3,14 @@ import '../utils/isolate_helper.dart';
import '../utils/json_utils.dart'; import '../utils/json_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:uuid/uuid.dart';
import '../media/download_resolution.dart'; import '../media/download_resolution.dart';
import '../media/library_filter_result.dart'; import '../media/library_filter_result.dart';
import '../media/library_first_character.dart'; import '../media/library_first_character.dart';
import '../media/library_query.dart'; import '../media/library_query.dart';
import '../media/live_tv_support.dart'; import '../media/live_tv_support.dart';
import '../media/lyrics.dart';
import '../media/media_backend.dart'; import '../media/media_backend.dart';
import '../media/media_hub.dart'; import '../media/media_hub.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
@@ -50,9 +52,11 @@ import '../models/plex/plex_match_result.dart';
import '../utils/codec_utils.dart'; import '../utils/codec_utils.dart';
import '../utils/content_utils.dart'; import '../utils/content_utils.dart';
import '../media/media_sort.dart'; import '../media/media_sort.dart';
import '../models/audio_quality_preset.dart';
import '../models/plex/plex_video_playback_data.dart'; import '../models/plex/plex_video_playback_data.dart';
import '../models/transcode_quality_preset.dart'; import '../models/transcode_quality_preset.dart';
import '../utils/device_identity.dart'; import '../utils/device_identity.dart';
import '../utils/lrc_parser.dart';
import '../utils/failover_http_client.dart'; import '../utils/failover_http_client.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/media_server_retry.dart'; import '../utils/media_server_retry.dart';
@@ -66,6 +70,7 @@ import '../i18n/strings.g.dart';
import '../mpv/mpv.dart'; import '../mpv/mpv.dart';
import 'api_cache.dart'; import 'api_cache.dart';
import 'plex_api_cache.dart'; import 'plex_api_cache.dart';
import 'plex_constants.dart';
import 'plex_mappers.dart'; import 'plex_mappers.dart';
import 'plex_playback_mapper.dart'; import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart'; import 'playback_initialization_types.dart';
@@ -1285,14 +1290,15 @@ class PlexClient
/// Search across all libraries including individually shared items. /// Search across all libraries including individually shared items.
/// Uses /library/search (same endpoint as Plex Web) which finds shared content. /// 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 { Future<List<PlexMetadataDto>> _search(String query, {int limit = 100}) async {
final response = await _getWithFailover( final response = await _getWithFailover(
'/library/search', '/library/search',
queryParameters: { queryParameters: {
'query': query, 'query': query,
'limit': limit, 'limit': limit,
'searchTypes': 'movies,tv', 'searchTypes': 'movies,tv,music',
'includeCollections': 1, 'includeCollections': 1,
'includeExternalMedia': 1, 'includeExternalMedia': 1,
'X-Plex-Container-Size': limit, 'X-Plex-Container-Size': limit,
@@ -1314,7 +1320,8 @@ class PlexClient
if (metadata is! Map<String, dynamic>) continue; if (metadata is! Map<String, dynamic>) continue;
final type = metadata['type'] as String?; 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)); results.add(_createTaggedMetadata(metadata));
} catch (e) { } catch (e) {
@@ -1574,15 +1581,7 @@ class PlexClient
Future<MediaFileInfo?> _fetchFileInfo(String ratingKey) async { Future<MediaFileInfo?> _fetchFileInfo(String ratingKey) async {
try { try {
final data = await fetchWithCacheFirst<Map<String, dynamic>>( final metadataJson = await _fetchRawMetadataJsonCacheFirst(ratingKey);
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);
return parsePlexFileInfoFromJson(metadataJson); return parsePlexFileInfoFromJson(metadataJson);
} catch (e) { } catch (e) {
appLogger.e('Failed to get file info: $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`). /// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`).
/// ///
/// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or /// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or
@@ -1780,7 +1798,19 @@ class PlexClient
]; ];
} }
try { 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); final sorts = _extractDirectoryList(response, MediaSort.fromJson);
// Fallback: return common sort options if API doesn't provide them // Fallback: return common sort options if API doesn't provide them
@@ -2115,19 +2145,25 @@ class PlexClient
return createPlaylistFromUri(title: title); return createPlaylistFromUri(title: title);
} }
final uri = await buildMetadataUri(items.map((i) => i.id).join(',')); 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 /// Create a new playlist
/// [title] - Name of the 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") /// [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 /// [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] /// Errors propagate to the caller (matches the [MediaServerClient]
/// contract — throw on HTTP/transport failures, return `null` only when /// contract — throw on HTTP/transport failures, return `null` only when
/// the server replied 2xx but with no usable playlist payload). /// the server replied 2xx but with no usable playlist payload).
Future<MediaPlaylist?> createPlaylistFromUri({required String title, String? uri, int? playQueueId}) async { Future<MediaPlaylist?> createPlaylistFromUri({
final queryParams = <String, dynamic>{'type': 'video', 'title': title, 'smart': '0'}; required String title,
String? uri,
int? playQueueId,
String type = 'video',
}) async {
final queryParams = <String, dynamic>{'type': type, 'title': title, 'smart': '0'};
if (uri != null) { if (uri != null) {
queryParams['uri'] = uri; queryParams['uri'] = uri;
@@ -3009,53 +3045,114 @@ class PlexClient
selectedSubtitleTrack: selectedSubtitleTrack, selectedSubtitleTrack: selectedSubtitleTrack,
offsetMs: offsetMs, offsetMs: offsetMs,
); );
return await _runTranscodeDecision(
final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); startEndpoint: _videoTranscodeStartEndpoint,
allParams: allParams,
final decisionClient = MediaServerHttpClient( isOriginal: preset.isOriginal,
connectTimeout: MediaServerTimeouts.connect,
receiveTimeout: MediaServerTimeouts.receive,
defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'},
); );
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) { } catch (e, st) {
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st); appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
return (startPath: null, outcome: TranscodeDecisionOutcome.failed); 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 startParams = Map<String, String>.from(params)..remove('X-Plex-Token');
final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
return '/video/:/transcode/universal/start?$startQuery'; return '$endpoint?$startQuery';
} }
@visibleForTesting @visibleForTesting
String buildTranscodeStartPathFromParamsForTesting(Map<String, String> params) { String buildTranscodeStartPathFromParamsForTesting(
return _buildTranscodeStartPathFromParams(params); Map<String, String> params, {
String endpoint = _videoTranscodeStartEndpoint,
}) {
return _buildTranscodeStartPathFromParams(params, endpoint: endpoint);
} }
Map<String, String> _buildTranscodeParams({ 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 /// Platform name Plex Media Server accepts on the transcode decision
/// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400, /// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400,
/// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected. /// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected.
@@ -3367,12 +3521,34 @@ class PlexClient
@override @override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async { 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( final leaves = await _fetchAllPages(
(start, size, abort) => _getGrandchildrenPage(parentId, start: start, size: size, abort: abort), (start, size, abort) => _getGrandchildrenPage(parentId, start: start, size: size, abort: abort),
); );
return leaves.map((m) => PlexMappers.mediaItem(m)).toList(); 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 @override
Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage( Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
String parentId, { String parentId, {
@@ -3394,6 +3570,76 @@ class PlexClient
@override @override
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async => null; 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, /// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata,
/// then either runs the transcode-decision flow or returns the direct-play /// then either runs the transcode-decision flow or returns the direct-play
/// URL. External subtitle tracks are absolutized with the server's auth /// URL. External subtitle tracks are absolutized with the server's auth
@@ -3414,8 +3660,39 @@ class PlexClient
throw PlaybackException(t.messages.fileInfoNotAvailable); 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 (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 resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
final resumeOffsetMs = options.metadata.viewOffsetMs; final resumeOffsetMs = options.metadata.viewOffsetMs;
final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo); final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo);
@@ -3448,24 +3725,7 @@ class PlexClient
); );
} }
// Decision failed or said direct-play only — fall through to direct-play path return _transcodeFallbackResult(data, result.outcome, options);
// 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 PlaybackInitializationResult( 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: /// Pick the audio stream ID to send to the transcoder. Preference order:
/// explicit [explicit] → audio track with `selected == true` → first → null. /// explicit [explicit] → audio track with `selected == true` → first → null.
int? _resolveAudioStreamId(int? explicit, MediaSourceInfo? info) { int? _resolveAudioStreamId(int? explicit, MediaSourceInfo? info) {
+1
View File
@@ -8,6 +8,7 @@ class PlexStreamType {
static const int video = 1; static const int video = 1;
static const int audio = 2; static const int audio = 2;
static const int subtitle = 3; static const int subtitle = 3;
static const int lyrics = 4;
} }
/// Plex metadata `type` integer codes — the value that goes in the /// Plex metadata `type` integer codes — the value that goes in the
+6
View File
@@ -15,6 +15,7 @@ import 'base_shared_preferences_service.dart';
import 'device_performance.dart'; import 'device_performance.dart';
export 'base_shared_preferences_service.dart' export 'base_shared_preferences_service.dart'
show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref; show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref;
import '../models/audio_quality_preset.dart';
import '../models/transcode_quality_preset.dart'; import '../models/transcode_quality_preset.dart';
import '../navigation/navigation_tabs.dart'; import '../navigation/navigation_tabs.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
@@ -420,6 +421,11 @@ class SettingsService extends BaseSharedPreferencesService {
values: TranscodeQualityPreset.values, values: TranscodeQualityPreset.values,
defaultValue: TranscodeQualityPreset.original, 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 autoPlayNextEpisode = BoolPref('auto_play_next_episode', defaultValue: true);
static const useExoPlayer = BoolPref('use_exoplayer', defaultValue: true); static const useExoPlayer = BoolPref('use_exoplayer', defaultValue: true);
static const startupSection = EnumPref<NavigationTabId>( static const startupSection = EnumPref<NavigationTabId>(
+63
View File
@@ -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(results.map((item) => item.id), ['jf-show']);
expect(plexRequests.single.queryParameters['limit'], '100'); expect(plexRequests.single.queryParameters['limit'], '100');
expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv'); expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv,music');
expect(jellyfinRequests.single.queryParameters['Limit'], '100'); // 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 { test('getOnDeckFromAllServers forwards preview limit to clients', () async {
+22 -1
View File
@@ -90,6 +90,26 @@ void main() {
expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/stream'); 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 { test('buildDirectStreamUrl canonicalizes a mixed-case scheme from stored config', () async {
// This URL bypasses Dart's Uri normalization on its way to the player, // This URL bypasses Dart's Uri normalization on its way to the player,
// and FFmpeg's protocol lookup is case-sensitive — a stored // and FFmpeg's protocol lookup is case-sensitive — a stored
@@ -2952,7 +2972,8 @@ void main() {
expect(requestUri, isNotNull); expect(requestUri, isNotNull);
expect(requestUri!.queryParameters['ParentId'], 'show-1'); expect(requestUri!.queryParameters['ParentId'], 'show-1');
expect(requestUri!.queryParameters['Recursive'], 'true'); 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['StartIndex'], '20');
expect(requestUri!.queryParameters['Limit'], '10'); 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')));
});
}
+2 -2
View File
@@ -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 captured = <Uri>[];
final client = makeClient((request) async { final client = makeClient((request) async {
captured.add(request.url); captured.add(request.url);
@@ -66,6 +66,6 @@ void main() {
expect(captured.single.path, '/library/search'); expect(captured.single.path, '/library/search');
expect(captured.single.queryParameters['limit'], '100'); expect(captured.single.queryParameters['limit'], '100');
expect(captured.single.queryParameters['X-Plex-Container-Size'], '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');
}); });
} }
+59
View File
@@ -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);
});
});
}