feat(music): add music data layer for plex and jellyfin
Backend-neutral fetchArtistAlbums/fetchAlbumTracks/fetchInstantMix/ fetchLyrics, lyrics model + LRC parser, audio quality presets, music transcode (plex /music universal, jellyfin audio PlaybackInfo profile), station/InstantMix radio, music search, playlist type derivation, and audio download resolution.
This commit is contained in:
@@ -700,6 +700,9 @@
|
|||||||
"nextUpIn": "Next Up in ${library}",
|
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// One lyric line. [startMs] is the offset from track start, `null` when the
|
||||||
|
/// source is unsynced plain text.
|
||||||
|
class LyricLine {
|
||||||
|
final String text;
|
||||||
|
final int? startMs;
|
||||||
|
|
||||||
|
const LyricLine({required this.text, this.startMs});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track lyrics as returned by [MediaServerClient.fetchLyrics].
|
||||||
|
///
|
||||||
|
/// [synced] is true when (enough) lines carry [LyricLine.startMs] for the
|
||||||
|
/// player to highlight/scroll along with playback. Jellyfin's `LyricDto`
|
||||||
|
/// omits its `IsSynced` flag on some server versions, so implementations
|
||||||
|
/// infer synced-ness from the presence of per-line offsets.
|
||||||
|
class Lyrics {
|
||||||
|
final bool synced;
|
||||||
|
final List<LyricLine> lines;
|
||||||
|
|
||||||
|
const Lyrics({required this.synced, required this.lines});
|
||||||
|
|
||||||
|
bool get isEmpty => lines.isEmpty;
|
||||||
|
}
|
||||||
@@ -464,6 +464,34 @@ sealed class MediaItem with _$MediaItem {
|
|||||||
return null;
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/// Music streaming quality presets.
|
||||||
|
///
|
||||||
|
/// When a non-[original] preset is selected, track playback asks the active
|
||||||
|
/// backend for a bitrate-capped audio transcode (Plex
|
||||||
|
/// `/music/:/transcode/universal` with `musicBitrate`, Jellyfin `PlaybackInfo`
|
||||||
|
/// with a capped `MaxStreamingBitrate`). [original] bypasses transcoding and
|
||||||
|
/// direct-plays the source file. Deliberately separate from
|
||||||
|
/// [TranscodeQualityPreset] — its members are video-shaped
|
||||||
|
/// (resolution/videoQuality).
|
||||||
|
enum AudioQualityPreset {
|
||||||
|
original(null),
|
||||||
|
high(320),
|
||||||
|
medium(192),
|
||||||
|
low(128);
|
||||||
|
|
||||||
|
const AudioQualityPreset(this.bitrateKbps);
|
||||||
|
|
||||||
|
final int? bitrateKbps;
|
||||||
|
|
||||||
|
bool get isOriginal => this == AudioQualityPreset.original;
|
||||||
|
|
||||||
|
String get storageKey => name;
|
||||||
|
|
||||||
|
static AudioQualityPreset fromStorage(String? stored) {
|
||||||
|
if (stored == null) return AudioQualityPreset.original;
|
||||||
|
for (final v in AudioQualityPreset.values) {
|
||||||
|
if (v.name == stored) return v;
|
||||||
|
}
|
||||||
|
return AudioQualityPreset.original;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order shared by every picker surface: [original] first, then capped
|
||||||
|
/// presets highest-bitrate first.
|
||||||
|
static final List<AudioQualityPreset> displayOrder = List.unmodifiable(values);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import 'file_info_parser.dart';
|
|||||||
import 'library_query_translator.dart';
|
import '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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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')));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/utils/lrc_parser.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('parseLrc', () {
|
||||||
|
test('parses synced lines ordered by tick', () {
|
||||||
|
final lyrics = parseLrc('[00:20.00]Second line\n[00:10.00]First line\n[01:05.00]Third line');
|
||||||
|
|
||||||
|
expect(lyrics, isNotNull);
|
||||||
|
expect(lyrics!.synced, isTrue);
|
||||||
|
expect(lyrics.lines.map((l) => l.text), ['First line', 'Second line', 'Third line']);
|
||||||
|
expect(lyrics.lines.map((l) => l.startMs), [10000, 20000, 65000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emits a multi-timestamp line once per timestamp', () {
|
||||||
|
final lyrics = parseLrc('[00:05.00]Verse\n[00:10.00][00:30.00]Chorus');
|
||||||
|
|
||||||
|
expect(lyrics!.lines.map((l) => l.text), ['Verse', 'Chorus', 'Chorus']);
|
||||||
|
expect(lyrics.lines.map((l) => l.startMs), [5000, 10000, 30000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies the offset tag and clamps below zero', () {
|
||||||
|
final lyrics = parseLrc('[offset:+500]\n[00:01.00]Late start\n[00:00.20]Clamped');
|
||||||
|
|
||||||
|
// offset is subtracted: 1000 - 500 = 500; 200 - 500 clamps to 0.
|
||||||
|
expect(lyrics!.lines.map((l) => l.startMs), [0, 500]);
|
||||||
|
expect(lyrics.lines.map((l) => l.text), ['Clamped', 'Late start']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scales fractional part by digit count', () {
|
||||||
|
final lyrics = parseLrc('[00:01.5]One digit\n[00:02.50]Two digits\n[00:03.500]Three digits');
|
||||||
|
|
||||||
|
expect(lyrics!.lines.map((l) => l.startMs), [1500, 2500, 3500]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips metadata id tags', () {
|
||||||
|
final lyrics = parseLrc('[ar:The Synth Pops]\n[ti:Dawn]\n[al:Album]\n[00:01.00]Actual line');
|
||||||
|
|
||||||
|
expect(lyrics!.synced, isTrue);
|
||||||
|
expect(lyrics.lines, hasLength(1));
|
||||||
|
expect(lyrics.lines.single.text, 'Actual line');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to unsynced plain text when no timestamps exist', () {
|
||||||
|
final lyrics = parseLrc('[ar:Artist]\nJust some words\nAnother line');
|
||||||
|
|
||||||
|
expect(lyrics, isNotNull);
|
||||||
|
expect(lyrics!.synced, isFalse);
|
||||||
|
expect(lyrics.lines.map((l) => l.text), ['Just some words', 'Another line']);
|
||||||
|
expect(lyrics.lines.every((l) => l.startMs == null), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for blank input', () {
|
||||||
|
expect(parseLrc(''), isNull);
|
||||||
|
expect(parseLrc(' \n\n '), isNull);
|
||||||
|
expect(parseLrc('[ar:Only tags]'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user