From fff559da06d97925d6a2a43c1ae307aabb748298 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:55:52 +0200 Subject: [PATCH] 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. --- lib/i18n/en.i18n.json | 3 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_en.g.dart | 16 +- lib/media/lyrics.dart | 23 + lib/media/media_item.dart | 28 ++ lib/media/media_server_client.dart | 30 ++ lib/media/server_capabilities.dart | 31 ++ lib/models/audio_quality_preset.dart | 35 ++ lib/services/jellyfin_client.dart | 4 + .../jellyfin_client/parts/browse.dart | 160 ++++++- .../parts/images_downloads.dart | 31 +- lib/services/jellyfin_client/parts/music.dart | 101 +++++ .../jellyfin_client/parts/playback.dart | 92 +++- .../jellyfin_client/parts/playlists.dart | 6 +- lib/services/jellyfin_mappers.dart | 61 ++- lib/services/jellyfin_playback_urls.dart | 6 +- .../playback_initialization_types.dart | 8 + lib/services/plex_client.dart | 427 +++++++++++++++--- lib/services/plex_constants.dart | 1 + lib/services/settings_service.dart | 6 + lib/utils/lrc_parser.dart | 63 +++ .../data_aggregation_bridge_test.dart | 9 +- test/services/jellyfin_client_urls_test.dart | 23 +- test/services/jellyfin_music_mapper_test.dart | 250 ++++++++++ test/services/plex_music_transcode_test.dart | 133 ++++++ test/services/plex_search_test.dart | 4 +- test/utils/lrc_parser_test.dart | 59 +++ 27 files changed, 1487 insertions(+), 125 deletions(-) create mode 100644 lib/media/lyrics.dart create mode 100644 lib/models/audio_quality_preset.dart create mode 100644 lib/services/jellyfin_client/parts/music.dart create mode 100644 lib/utils/lrc_parser.dart create mode 100644 test/services/jellyfin_music_mapper_test.dart create mode 100644 test/services/plex_music_transcode_test.dart create mode 100644 test/utils/lrc_parser_test.dart diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 846f3e6f..4c5dfd20 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -700,6 +700,9 @@ "nextUpIn": "Next Up in ${library}", "recentlyAdded": "Recently Added", "recentlyAddedIn": "Recently Added in ${library}", + "latestAlbumsIn": "Latest Albums in ${library}", + "recentlyPlayedIn": "Recently Played in ${library}", + "mostPlayedIn": "Most Played in ${library}", "playEpisode": "S${season}E${episode}", "overview": "Overview", "cast": "Cast", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index f557f265..53747078 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 20863 (1303 per locale) +/// Strings: 20866 (1304 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index d111743d..89aa6d6c 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2155,6 +2155,15 @@ class TranslationsDiscoverEn { /// en: 'Recently Added in ${library}' String recentlyAddedIn({required Object library}) => 'Recently Added in ${library}'; + /// en: 'Latest Albums in ${library}' + String latestAlbumsIn({required Object library}) => 'Latest Albums in ${library}'; + + /// en: 'Recently Played in ${library}' + String recentlyPlayedIn({required Object library}) => 'Recently Played in ${library}'; + + /// en: 'Most Played in ${library}' + String mostPlayedIn({required Object library}) => 'Most Played in ${library}'; + /// en: 'S${season}E${episode}' String playEpisode({required Object season, required Object episode}) => 'S${season}E${episode}'; @@ -5265,6 +5274,9 @@ extension on Translations { 'discover.nextUpIn' => ({required Object library}) => 'Next Up in ${library}', 'discover.recentlyAdded' => 'Recently Added', 'discover.recentlyAddedIn' => ({required Object library}) => 'Recently Added in ${library}', + 'discover.latestAlbumsIn' => ({required Object library}) => 'Latest Albums in ${library}', + 'discover.recentlyPlayedIn' => ({required Object library}) => 'Recently Played in ${library}', + 'discover.mostPlayedIn' => ({required Object library}) => 'Most Played in ${library}', 'discover.playEpisode' => ({required Object season, required Object episode}) => 'S${season}E${episode}', 'discover.overview' => 'Overview', 'discover.cast' => 'Cast', @@ -5632,11 +5644,11 @@ extension on Translations { 'shaders.title' => 'Shaders', 'shaders.noShaderDescription' => 'No video enhancement', 'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video', + _ => null, + } ?? switch (path) { 'shaders.artcnnVariantNeutral' => 'Neutral', 'shaders.artcnnVariantDenoise' => 'Denoise', 'shaders.artcnnVariantDenoiseSharpen' => 'Denoise + Sharpen', - _ => null, - } ?? switch (path) { 'shaders.qualityFast' => 'Fast', 'shaders.qualityHQ' => 'High Quality', 'shaders.mode' => 'Mode', diff --git a/lib/media/lyrics.dart b/lib/media/lyrics.dart new file mode 100644 index 00000000..8db751c1 --- /dev/null +++ b/lib/media/lyrics.dart @@ -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 lines; + + const Lyrics({required this.synced, required this.lines}); + + bool get isEmpty => lines.isEmpty; +} diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index c94bfee4..972dcb17 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -464,6 +464,34 @@ sealed class MediaItem with _$MediaItem { return null; } + /// Track number within its disc, for [MediaKind.track] items. + int? get trackNumber => kind == MediaKind.track ? index : null; + + /// Disc number for [MediaKind.track] items (Plex `parentIndex`, Jellyfin + /// `ParentIndexNumber`). Null/1 on single-disc albums. + int? get discNumber => kind == MediaKind.track ? parentIndex : null; + + /// Album title for music items: a track's parent, an album's own title. + String? get albumTitle => switch (kind) { + MediaKind.track => parentTitle, + MediaKind.album => title, + _ => null, + }; + + /// Album-artist name for music items: a track's grandparent, an album's + /// parent. + String? get albumArtistTitle => switch (kind) { + MediaKind.track => grandparentTitle, + MediaKind.album => parentTitle, + _ => null, + }; + + /// Performing artist of a track. Falls back to [albumArtistTitle] — both + /// backends only populate a separate value when it differs (Plex stores a + /// compilation track's own artist in `originalTitle`; the Jellyfin mapper + /// mirrors that convention from `Artists`). + String? get trackArtistTitle => kind == MediaKind.track ? (originalTitle ?? albumArtistTitle) : null; + /// Plex-only edition label. Jellyfin returns null. String? get editionTitle => null; diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index cad0ff61..a51dbb3b 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -12,6 +12,7 @@ import 'library_filter_result.dart'; import 'library_first_character.dart'; import 'library_query.dart'; import 'live_tv_support.dart'; +import 'lyrics.dart'; import 'media_backend.dart'; import 'media_file_info.dart'; import 'media_hub.dart'; @@ -235,6 +236,35 @@ abstract class MediaServerClient { /// series" via the null vs `[]` distinction. Future?> 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> 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> 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> 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 fetchLyrics(MediaItem track); + /// Free-text search across the user's libraries. Future> searchItems(String query, {int limit = 100}); diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 4cd98a70..505dd753 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -109,6 +109,22 @@ class ServerCapabilities { /// `/Items?ParentId=...&Recursive=false` queries. final bool folderGrouping; + /// Server can supply track lyrics. Jellyfin exposes `/Audio/{id}/Lyrics`; + /// Plex surfaces sidecar `.lrc`/`.txt` files as track streams + /// (`streamType 4`) fetched via `/library/streams/{id}`. Gates the lyrics + /// affordance in the music player; per-track absence is the runtime gate. + final bool lyrics; + + /// Server can build an "instant mix" / radio track list from a seed item. + /// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues + /// (`POST /playQueues?type=audio&uri=...station...`). + final bool instantMix; + + /// Server can transcode audio to a capped bitrate. Plex: + /// `/music/:/transcode/universal`; Jellyfin: `PlaybackInfo` with an audio + /// `TranscodingProfile`. Gates the music quality picker (vs original-only). + final bool audioTranscoding; + const ServerCapabilities({ this.serverSidePlayQueue = false, this.serverSidePlaylists = false, @@ -130,6 +146,9 @@ class ServerCapabilities { this.alphaBar = AlphaBarMode.none, this.scrubThumbnails = false, this.folderGrouping = false, + this.lyrics = false, + this.instantMix = false, + this.audioTranscoding = false, }); /// Defaults for a fully-featured Plex server. @@ -154,6 +173,9 @@ class ServerCapabilities { alphaBar: AlphaBarMode.scrollSnap, scrubThumbnails: true, folderGrouping: true, + lyrics: true, + instantMix: true, + audioTranscoding: true, ); /// Defaults for a Jellyfin server. @@ -185,6 +207,9 @@ class ServerCapabilities { alphaBar: AlphaBarMode.nameStartsWithFilter, scrubThumbnails: true, folderGrouping: true, + lyrics: true, + instantMix: true, + audioTranscoding: true, ); ServerCapabilities copyWith({ @@ -208,6 +233,9 @@ class ServerCapabilities { AlphaBarMode? alphaBar, bool? scrubThumbnails, bool? folderGrouping, + bool? lyrics, + bool? instantMix, + bool? audioTranscoding, }) { return ServerCapabilities( serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue, @@ -230,6 +258,9 @@ class ServerCapabilities { alphaBar: alphaBar ?? this.alphaBar, scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails, folderGrouping: folderGrouping ?? this.folderGrouping, + lyrics: lyrics ?? this.lyrics, + instantMix: instantMix ?? this.instantMix, + audioTranscoding: audioTranscoding ?? this.audioTranscoding, ); } } diff --git a/lib/models/audio_quality_preset.dart b/lib/models/audio_quality_preset.dart new file mode 100644 index 00000000..b9f76c54 --- /dev/null +++ b/lib/models/audio_quality_preset.dart @@ -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 displayOrder = List.unmodifiable(values); +} diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 44fe0bfa..a91c1bd5 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -15,6 +15,7 @@ import 'file_info_parser.dart'; import 'library_query_translator.dart'; import '../media/media_filter.dart'; import '../media/live_tv_support.dart'; +import '../media/lyrics.dart'; import '../media/media_backend.dart'; import '../media/media_file_info.dart'; import '../media/media_hub.dart'; @@ -26,6 +27,7 @@ import '../media/ids.dart'; import '../media/media_server_client.dart'; import '../media/playback_report_metadata.dart'; import '../media/server_capabilities.dart'; +import '../models/audio_quality_preset.dart'; import '../models/jellyfin/jellyfin_user_profile.dart'; import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; @@ -69,6 +71,7 @@ import 'scrub_preview_source.dart'; import '../mpv/mpv.dart'; part 'jellyfin_client/parts/browse.dart'; +part 'jellyfin_client/parts/music.dart'; part 'jellyfin_client/parts/playback.dart'; part 'jellyfin_client/parts/watch_state.dart'; part 'jellyfin_client/parts/playlists.dart'; @@ -88,6 +91,7 @@ class JellyfinClient with MediaServerCacheMixin, _JellyfinBrowseMethods, + _JellyfinMusicMethods, _JellyfinPlaybackMethods, _JellyfinWatchStateMethods, _JellyfinPlaylistMethods, diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 0b531c7a..bbf15e04 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -90,7 +90,7 @@ const _continueWatchingSeriesLookback = 200; const _childrenPageSize = 500; const _pagedListPageSize = 200; -const _playableDescendantTypes = 'Movie,Episode'; +const _playableDescendantTypes = 'Movie,Episode,Audio'; const _playableFolderDescendantTypes = 'Movie,Episode,Video,MusicVideo'; const _episodeOrderQueryParameters = { 'SortBy': 'ParentIndexNumber,IndexNumber,SortName', @@ -179,12 +179,35 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { ); final params = translator.toQueryParameters(query); - final response = await _http.get('/Items', queryParameters: params, abort: abort); + // Artist browsing routes to `/Artists/AlbumArtists` instead of + // `/Items?IncludeItemTypes=MusicArtist`: the /Items query only returns + // folder-derived artists under their folder names, missing tag-only + // per-track artists entirely (and folder names can differ from the tag + // names shown everywhere else). AlbumArtists matches Plex's "album + // artists" library semantic. The branch lives here rather than in the + // translator because the translator's contract is query *parameters* + // only — the endpoint choice is client routing, like the seasons vs + // generic-children split in [fetchChildrenPage]. The artists endpoint + // accepts the same paging/sort/filter/prefix params /Items does + // (ParentId, StartIndex, Limit, SortBy/SortOrder, NameStartsWith/ + // NameLessThan, Filters, Fields) and ignores the /Items-only keys. + final isArtistQuery = query.kind == MediaKind.artist; + final endpoint = isArtistQuery ? '/Artists/AlbumArtists' : '/Items'; + if (isArtistQuery) { + params.remove('IncludeItemTypes'); + params.remove('Recursive'); + } + + final response = await _http.get(endpoint, queryParameters: params, abort: abort); throwIfHttpError(response); final data = response.data; final items = _itemsArray(data); final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final total = rawTotal is int + // /Artists/AlbumArtists reports TotalRecordCount=0 when NameStartsWith / + // NameLessThan are set (server-side counting quirk, observed on 10.11); + // treat that as "unknown" so the alpha-prefix filter can still page. + final totalUnreliable = isArtistQuery && rawTotal == 0 && items.isNotEmpty; + final total = rawTotal is int && !totalUnreliable ? rawTotal : _fallbackPageTotal(offset: query.offset, itemCount: items.length, requestedSize: query.limit); return LibraryPage(items: _mapItems(items), totalCount: total, offset: query.offset); @@ -844,10 +867,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return _mapItems(allRaw); } - /// All directly-playable descendants of [parentId] (Movies + Episodes), - /// recursively expanded. Used by the playback launcher so a collection - /// containing a Series plays its episodes instead of the unplayable - /// Series entry, and a playlist mixing both comes through the same path. + /// All directly-playable descendants of [parentId] (Movies + Episodes + + /// Audio tracks), recursively expanded. Used by the playback launcher so a + /// collection containing a Series plays its episodes instead of the + /// unplayable Series entry, a playlist mixing both comes through the same + /// path, and an album/artist/audio-playlist expands to its tracks. /// Direct browsing keeps using [fetchChildren] / [fetchPlaylistItems] /// since those preserve the container shape (Series rows, PlaylistItemId). /// @@ -978,20 +1002,28 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { @override Future> searchItems(String query, {int limit = 100}) async { - final response = await _http.get( - '/Items', - queryParameters: { + // Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=` + // only matches folder-derived MusicArtist rows (under folder names), so + // tag-only artists would never appear in search. The artists leg is + // best-effort — a music-endpoint hiccup shouldn't sink video search. + final results = await Future.wait([ + _fetchItemsArray('/Items', { 'userId': connection.userId, 'SearchTerm': query, 'Recursive': 'true', 'Limit': limit.toString(), - 'IncludeItemTypes': 'Movie,Series,Episode', + 'IncludeItemTypes': 'Movie,Series,Episode,MusicAlbum,Audio', 'Fields': _browseFields, ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - return _mapItems(_itemsArray(response.data)); + }), + _safeFetchItemsArray('/Artists', { + 'userId': connection.userId, + 'searchTerm': query, + 'Limit': limit.toString(), + ...jellyfinImageQueryParameters, + }), + ]); + return _mapItems([...results.first, ...results[1]]); } @override @@ -1192,6 +1224,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { ...jellyfinImageQueryParameters, }, retry: _libraryHubRetry); + // Music libraries get their own hub set (Latest Albums / Recently Played / + // Most Played) — Resume and NextUp are video concepts and Jellyfin's + // Resume endpoint is queried with MediaTypes=Video anyway. The branch + // ignores [includePlaybackHubs]: the played rows never duplicate the + // app-level Continue Watching shelf that flag exists to dedupe. + if (libraryKind == MediaKind.artist) { + return _fetchMusicLibraryHubs(libraryId, libraryName: libraryName, limit: limit, latestFuture: latestFuture); + } + if (!includePlaybackHubs) { final latest = await latestFuture; return [ @@ -1268,11 +1309,76 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { ].where((h) => h.items.isNotEmpty).toList(); } + /// Music-library hub set, mirroring the Jellyfin web client's music + /// "Suggestions" tab. `Latest Albums` reuses the `.recent` identifier — + /// `/Users/{userId}/Items/Latest` natively groups a music library's new + /// items into albums, so the existing `recent` paging path in + /// [fetchMoreHubItemsPage] is already the correct expansion. The played + /// rows filter `IsPlayed` so unplayed tracks (PlayCount 0) never pad them. + Future> _fetchMusicLibraryHubs( + String libraryId, { + required String libraryName, + required int limit, + required Future>> latestFuture, + }) async { + final playedParams = { + 'userId': connection.userId, + 'ParentId': libraryId, + 'IncludeItemTypes': 'Audio', + 'Recursive': 'true', + 'Filters': 'IsPlayed', + 'SortOrder': 'Descending', + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, + }; + final results = await Future.wait([ + latestFuture, + _safeFetchItemsArray('/Items', {...playedParams, 'SortBy': 'DatePlayed'}, retry: _libraryHubRetry), + _safeFetchItemsArray('/Items', {...playedParams, 'SortBy': 'PlayCount'}, retry: _libraryHubRetry), + ]); + + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.recent', + title: t.discover.latestAlbumsIn(library: libraryName), + type: 'album', + items: results.first, + previewLimit: limit, + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.recentlyplayed', + title: t.discover.recentlyPlayedIn(library: libraryName), + type: 'track', + items: results[1], + previewLimit: limit, + serverId: serverId, + serverName: serverName, + ), + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.mostplayed', + title: t.discover.mostPlayedIn(library: libraryName), + type: 'track', + items: results[2], + previewLimit: limit, + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + /// Re-run the synthetic hub query without the preview limit so the /// hub-detail screen can render the full list. Branches on the /// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]: /// `home.recent` / `library.{id}.recent` → Latest, `*.continue` → Resume, - /// `*.nextup` → NextUp. Unknown ids return an empty list. + /// `*.nextup` → NextUp, `*.recentlyplayed` / `*.mostplayed` → the music + /// played-track queries. Unknown ids return an empty list. @override Future> fetchMoreHubItems(String hubId, {int? limit}) async { try { @@ -1356,6 +1462,28 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { requestedSize: pageSize, abort: abort, ); + case 'recentlyplayed': + case 'mostplayed': + return _safeFetchMediaPage( + '/Items', + { + 'userId': connection.userId, + 'ParentId': ?parentId, + 'IncludeItemTypes': 'Audio', + 'Recursive': 'true', + 'Filters': 'IsPlayed', + 'SortBy': tail == 'mostplayed' ? 'PlayCount' : 'DatePlayed', + 'SortOrder': 'Descending', + 'StartIndex': offset.toString(), + 'Limit': effectiveLimit, + 'Fields': _browseFields, + 'EnableTotalRecordCount': 'true', + ...jellyfinImageQueryParameters, + }, + offset: offset, + requestedSize: pageSize, + abort: abort, + ); default: return LibraryPage(items: const [], totalCount: 0, offset: offset); } diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index 91a24774..a1093f55 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -16,6 +16,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { String? liveStreamId, int? audioStreamIndex, }); + String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}); Future?> getPlaybackInfo( String itemId, { int? maxStreamingBitrate = 100_000_000, @@ -30,6 +31,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { bool? enableTranscoding, bool? allowVideoStreamCopy, bool? allowAudioStreamCopy, + bool audioProfile, }); String _withApiKey(String urlOrPath); @@ -55,19 +57,36 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { @override Future resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async { + // Tracks stream from /Audio/{id}/stream; the URL contract (Static=true, + // api_key in the query string) is otherwise identical to the video one. + final isTrack = item.kind == MediaKind.track; final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex, sourceId: mediaSourceId); - if (bundle == null) return buildDirectStreamUrl(item.id); - return buildDirectStreamUrl( - item.id, - container: bundle.container, - mediaSourceId: bundle.pinnedSourceIdForItem(item.id), - ); + if (bundle == null) { + return isTrack ? buildAudioDirectStreamUrl(item.id) : buildDirectStreamUrl(item.id); + } + final container = bundle.container; + final pinnedSourceId = bundle.pinnedSourceIdForItem(item.id); + return isTrack + ? buildAudioDirectStreamUrl(item.id, container: container, mediaSourceId: pinnedSourceId) + : buildDirectStreamUrl(item.id, container: container, mediaSourceId: pinnedSourceId); } @override Future resolveDownload(MediaItem item, {int mediaIndex = 0}) async { final bundle = await fetchPlaybackBundle(item.id, sourceIndex: mediaIndex); final selectedSourceId = bundle?.selectedSourceId; + + // Tracks download from the audio static-stream endpoint and have no + // subtitle sidecars to enumerate. + if (item.kind == MediaKind.track) { + final audioUrl = buildAudioDirectStreamUrl( + item.id, + container: bundle?.container, + mediaSourceId: bundle?.pinnedSourceIdForItem(item.id), + ); + return DownloadResolution(videoUrl: audioUrl, mediaSourceId: selectedSourceId, externalSubtitles: const []); + } + // Direct-stream the selected original file. Jellyfin's `Static=true` // skips the transcoder so the byte-for-byte source lands on disk. final videoUrl = buildDirectStreamUrl( diff --git a/lib/services/jellyfin_client/parts/music.dart b/lib/services/jellyfin_client/parts/music.dart new file mode 100644 index 00000000..60a5825d --- /dev/null +++ b/lib/services/jellyfin_client/parts/music.dart @@ -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 _mapItems(Iterable> 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> 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> 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> 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 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) return null; + final rawLines = data['Lyrics']; + if (rawLines is! List) return null; + final lines = []; + var synced = false; + for (final raw in rawLines) { + if (raw is! Map) 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; + } + } +} diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 43aaf0e7..3a858d16 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -159,11 +159,24 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { var isTranscoding = false; TranscodeFallbackReason? fallbackReason; + // Tracks negotiate with the audio device profile and ignore the + // (video-shaped) [PlaybackInitializationOptions.qualityPreset]; capping + // comes from [PlaybackInitializationOptions.audioQualityPreset] instead. + // Original / null keeps the unlimited default so high-bitrate lossless + // files direct-play uncapped. + final isTrack = metadata.kind == MediaKind.track; final preset = options.qualityPreset; + final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original; + final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal; final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo); - final int? maxStreamingBitrate = preset.isOriginal ? null : (preset.videoBitrateKbps ?? 100_000) * 1000; + final int? maxStreamingBitrate = wantsOriginal + ? null + : isTrack + // Non-original audio presets always carry a bitrate by construction. + ? audioPreset.bitrateKbps! * 1000 + : (preset.videoBitrateKbps ?? 100_000) * 1000; final resumeOffsetMs = metadata.viewOffsetMs; - final int? transcodeStartTimeTicks = !preset.isOriginal && resumeOffsetMs != null && resumeOffsetMs > 0 + final int? transcodeStartTimeTicks = !wantsOriginal && resumeOffsetMs != null && resumeOffsetMs > 0 ? msToJellyfinTicks(resumeOffsetMs) : null; final negotiation = await getPlaybackInfo( @@ -172,9 +185,10 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { mediaSourceId: bundle.selectedSourceId, startTimeTicks: transcodeStartTimeTicks, audioStreamIndex: requestedAudioStreamId, + audioProfile: isTrack, ); if (negotiation == null) { - if (!preset.isOriginal) { + if (!wantsOriginal) { fallbackReason = TranscodeFallbackReason.decisionFailed; } } else { @@ -200,7 +214,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { final transcodingUrl = chosenSource['TranscodingUrl']; final directStreamUrl = chosenSource['DirectStreamUrl']; - if (!preset.isOriginal && transcodingUrl is String && transcodingUrl.isNotEmpty) { + if (!wantsOriginal && transcodingUrl is String && transcodingUrl.isNotEmpty) { // TranscodingUrl is server-relative and already encodes container, // codecs, MediaSourceId, and PlaySessionId; we just append the // api_key for auth. @@ -214,25 +228,31 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { videoUrl = _withApiKey(directStreamUrl); playMethod = 'DirectStream'; } else { - if (!preset.isOriginal) { + if (!wantsOriginal) { fallbackReason = TranscodeFallbackReason.directPlayOnly; } } - } else if (!preset.isOriginal) { + } else if (!wantsOriginal) { fallbackReason = TranscodeFallbackReason.directPlayOnly; } } final effectiveAudioStreamId = _resolveJellyfinAudioStreamId(requestedAudioStreamId, mediaInfo); mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId); - final externalSubtitles = _buildExternalSubtitles( - metadata.id, - effectiveSourceId, - mediaInfo, - includeExternalDelivery: includeExternalSubtitleDelivery, - ); + // Tracks have no subtitle streams to assemble (a `Lyric` stream may be + // present, but lyrics flow through fetchLyrics, not the subtitle path). + final externalSubtitles = isTrack + ? const [] + : _buildExternalSubtitles( + metadata.id, + effectiveSourceId, + mediaInfo, + includeExternalDelivery: includeExternalSubtitleDelivery, + ); final pinnedSourceId = bundle.pinnedSourceIdForItem(metadata.id); - videoUrl ??= buildDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId); + videoUrl ??= isTrack + ? buildAudioDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId) + : buildDirectStreamUrl(metadata.id, container: effectiveContainer, mediaSourceId: pinnedSourceId); return PlaybackInitializationResult( availableVersions: bundle.availableVersions, @@ -436,6 +456,21 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { ); } + /// Audio sibling of [buildDirectStreamUrl]: `/Audio/{id}/stream` with the + /// same `Static=true` + `api_key` + `DeviceId` self-authentication. Used + /// for track direct-play fallback, downloads, and external players. + String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) { + return buildJellyfinDirectStreamUrl( + baseUrl: connection.baseUrl, + accessToken: connection.accessToken, + deviceId: connection.deviceId, + itemId: itemId, + mediaSegment: 'Audio', + container: container, + mediaSourceId: mediaSourceId, + ); + } + /// Trickplay sprite-sheet URL. [width] picks one of the resolutions /// declared in `BaseItemDto.Trickplay`; [sheetIndex] is the zero-based /// sheet number (each sheet packs `tileWidth * tileHeight` thumbnails). @@ -469,6 +504,9 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { /// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams /// to pick for the transcode profile (Jellyfin's negotiation factors them in /// when picking codec compatibility). + /// [audioProfile] extends the DeviceProfile with music direct-play and + /// audio→mp3 transcode entries for track playback; the video profiles (and + /// the request body when false) are untouched either way. Future?> getPlaybackInfo( String itemId, { int? maxStreamingBitrate = 100_000_000, @@ -483,6 +521,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { bool? enableTranscoding, bool? allowVideoStreamCopy, bool? allowAudioStreamCopy, + bool audioProfile = false, }) async { try { final query = { @@ -526,26 +565,45 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { // ahead of H.264 so a server that has "Allow encoding in HEVC // format" enabled will actually emit HEVC instead of falling // back to H.264. - 'TranscodingProfiles': const >[ - { + 'TranscodingProfiles': >[ + const { 'Type': 'Video', 'Container': 'ts', 'Protocol': 'hls', 'VideoCodec': 'hevc,h264', 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus', }, + // Track playback transcode target: stereo mp3 over plain http. + // Appended after the video profile so the first-entry-wins + // ordering for video output codecs is untouched. + if (audioProfile) + const { + 'Type': 'Audio', + 'Container': 'mp3', + 'AudioCodec': 'mp3', + 'Protocol': 'http', + 'Context': 'Streaming', + 'MaxAudioChannels': '2', + }, ], // Declaring HEVC in DirectPlayProfile.VideoCodec stops the server // from forcing a transcode for HEVC sources whose container we // already accept — mpv decodes HEVC natively on every platform // we ship. - 'DirectPlayProfiles': const >[ - { + 'DirectPlayProfiles': >[ + const { 'Type': 'Video', 'Container': 'mp4,mkv,m4v,webm,mov,ts', 'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video', 'AudioCodec': 'aac,mp3,mp2,ac3,eac3,flac,opus,vorbis,dts', }, + // Music containers/codecs mpv plays natively everywhere. + if (audioProfile) + const { + 'Type': 'Audio', + 'Container': 'flac,mp3,ogg,oga,opus,m4a,m4b,aac,alac,wav,aiff,wma,webma', + 'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma', + }, ], 'SubtitleProfiles': const >[ {'Format': 'srt', 'Method': 'External'}, diff --git a/lib/services/jellyfin_client/parts/playlists.dart b/lib/services/jellyfin_client/parts/playlists.dart index 2d218ad0..599a10a8 100644 --- a/lib/services/jellyfin_client/parts/playlists.dart +++ b/lib/services/jellyfin_client/parts/playlists.dart @@ -144,13 +144,17 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { @override Future createPlaylist({required String title, required List items}) async { + // MediaType stamps the playlist's kind server-side; derive it from the + // seed items so music selections create Audio playlists (which is what + // fetchPlaylistsPage filters on). Empty seeds keep the Video default. + final isMusic = items.isNotEmpty && items.first.kind.isMusic; final response = await _http.post( '/Playlists', queryParameters: { 'Name': title, 'Ids': items.map((i) => i.id).join(','), 'UserId': connection.userId, - 'MediaType': 'Video', + 'MediaType': isMusic ? 'Audio' : 'Video', }, ); throwIfHttpError(response); diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index a0404c7b..afb7e497 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -164,21 +164,38 @@ class JellyfinMappers { titleSort: item['SortName'] as String?, summary: item['Overview'] as String?, tagline: _firstString(item['Taglines']), - originalTitle: item['OriginalTitle'] as String?, + // Music: a compilation track's own performer(s) go into originalTitle + // (matching Plex's convention) so MediaItem.trackArtistTitle can prefer + // it over the album artist. Only set when it actually differs. + originalTitle: item['OriginalTitle'] as String? ?? _trackArtistsOriginalTitle(item), studio: _firstStudioName(item['Studios']), year: item['ProductionYear'] as int?, originallyAvailableAt: jellyfinIsoToYmd(item['PremiereDate'] as String?), contentRating: item['OfficialRating'] as String?, - parentId: item['SeasonId'] as String? ?? item['ParentId'] as String?, - parentTitle: item['SeasonName'] as String?, + // Music mirrors the episode hierarchy, matching Plex's parent chain: + // track parent = album (AlbumId / Album), track grandparent = album + // artist (AlbumArtists[0] / AlbumArtist), and an *album's* parent is its + // artist (Jellyfin album dtos link artists via tags, not ParentId). + // Video rows never carry the Album* fields, so the extra fallbacks are + // inert for them. + parentId: + item['SeasonId'] as String? ?? + item['AlbumId'] as String? ?? + (kind == MediaKind.album ? _firstAlbumArtistId(item) : null) ?? + item['ParentId'] as String?, + parentTitle: + item['SeasonName'] as String? ?? + item['Album'] as String? ?? + (kind == MediaKind.album ? item['AlbumArtist'] as String? : null), parentThumbPath: _imagePath(item, 'SeasonId', 'SeasonPrimaryImageTag', 'Primary'), parentIndex: item['ParentIndexNumber'] as int?, index: item['IndexNumber'] as int?, - grandparentId: item['SeriesId'] as String?, - grandparentTitle: item['SeriesName'] as String?, + grandparentId: item['SeriesId'] as String? ?? (kind == MediaKind.track ? _firstAlbumArtistId(item) : null), + grandparentTitle: + item['SeriesName'] as String? ?? (kind == MediaKind.track ? item['AlbumArtist'] as String? : null), grandparentThumbPath: _seriesPrimaryImage(item), grandparentArtPath: _parentBackdropImage(item) ?? _seriesBackdropImage(item), - thumbPath: _selfImagePath(id, item, 'Primary'), + thumbPath: _selfImagePath(id, item, 'Primary') ?? _albumPrimaryImage(item), artPath: _selfImagePath(id, item, 'Backdrop'), // Episodes/seasons don't carry their own logo — Jellyfin exposes the // parent's logo via ParentLogoItemId/ParentLogoImageTag, which is @@ -472,6 +489,38 @@ class JellyfinMappers { return _itemImagePath(id, type, tag: tag); } + /// First album-artist id for Audio/MusicAlbum rows — the music counterpart + /// of `SeriesId` in the parent hierarchy. + static String? _firstAlbumArtistId(Map item) { + final albumArtists = item['AlbumArtists']; + if (albumArtists is List && albumArtists.isNotEmpty) { + final first = albumArtists.first; + if (first is Map) 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 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 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 item) { final seriesId = item['SeriesId'] as String?; if (seriesId == null) return null; diff --git a/lib/services/jellyfin_playback_urls.dart b/lib/services/jellyfin_playback_urls.dart index dc1d57ee..6107cf28 100644 --- a/lib/services/jellyfin_playback_urls.dart +++ b/lib/services/jellyfin_playback_urls.dart @@ -1,8 +1,12 @@ +/// [mediaSegment] selects the endpoint family: Jellyfin serves the identical +/// static-stream contract under `/Videos/{id}/stream` and `/Audio/{id}/stream` +/// — music track playback passes `'Audio'`. String buildJellyfinDirectStreamUrl({ required String baseUrl, required String accessToken, required String deviceId, required String itemId, + String mediaSegment = 'Videos', String? container, String? mediaSourceId, String? playSessionId, @@ -20,7 +24,7 @@ String buildJellyfinDirectStreamUrl({ 'AudioStreamIndex': ?audioStreamIndex?.toString(), }; final encodedItem = Uri.encodeComponent(itemId); - return '$baseUrl/Videos/$encodedItem/stream?${_encodeQuery(params)}'; + return '$baseUrl/$mediaSegment/$encodedItem/stream?${_encodeQuery(params)}'; } String buildJellyfinTrickplayTileUrl({ diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index 1d5c6892..94ea9fa6 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -1,6 +1,7 @@ import '../media/media_item.dart'; import '../media/media_source_info.dart'; import '../media/media_version.dart'; +import '../models/audio_quality_preset.dart'; import '../models/transcode_quality_preset.dart'; import '../mpv/mpv.dart'; @@ -28,6 +29,12 @@ class PlaybackInitializationOptions { /// server to transcode when supported. final TranscodeQualityPreset qualityPreset; + /// Music transcode preset, consulted only when [metadata] is a + /// [MediaKind.track]. `original` (or null) direct-plays; anything else asks + /// for a bitrate-capped audio transcode. [qualityPreset] is ignored for + /// tracks — video presets are resolution-shaped. + final AudioQualityPreset? audioQualityPreset; + /// Audio stream id forwarded to the transcoder. `null` means "let the /// server pick". final int? selectedAudioStreamId; @@ -45,6 +52,7 @@ class PlaybackInitializationOptions { this.selectedMediaSourceId, this.preferredVersionSignature, this.qualityPreset = TranscodeQualityPreset.original, + this.audioQualityPreset, this.selectedAudioStreamId, this.sessionIdentifier, this.transcodeSessionId, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 9e12421d..ed5eb512 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3,12 +3,14 @@ import '../utils/isolate_helper.dart'; import '../utils/json_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:uuid/uuid.dart'; import '../media/download_resolution.dart'; import '../media/library_filter_result.dart'; import '../media/library_first_character.dart'; import '../media/library_query.dart'; import '../media/live_tv_support.dart'; +import '../media/lyrics.dart'; import '../media/media_backend.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; @@ -50,9 +52,11 @@ import '../models/plex/plex_match_result.dart'; import '../utils/codec_utils.dart'; import '../utils/content_utils.dart'; import '../media/media_sort.dart'; +import '../models/audio_quality_preset.dart'; import '../models/plex/plex_video_playback_data.dart'; import '../models/transcode_quality_preset.dart'; import '../utils/device_identity.dart'; +import '../utils/lrc_parser.dart'; import '../utils/failover_http_client.dart'; import '../utils/app_logger.dart'; import '../utils/media_server_retry.dart'; @@ -66,6 +70,7 @@ import '../i18n/strings.g.dart'; import '../mpv/mpv.dart'; import 'api_cache.dart'; import 'plex_api_cache.dart'; +import 'plex_constants.dart'; import 'plex_mappers.dart'; import 'plex_playback_mapper.dart'; import 'playback_initialization_types.dart'; @@ -1285,14 +1290,15 @@ class PlexClient /// Search across all libraries including individually shared items. /// Uses /library/search (same endpoint as Plex Web) which finds shared content. - /// Only returns movies and shows, filtering out other types. + /// Only returns movies, shows, and music (artists/albums/tracks), filtering + /// out other types. Future> _search(String query, {int limit = 100}) async { final response = await _getWithFailover( '/library/search', queryParameters: { 'query': query, 'limit': limit, - 'searchTypes': 'movies,tv', + 'searchTypes': 'movies,tv,music', 'includeCollections': 1, 'includeExternalMedia': 1, 'X-Plex-Container-Size': limit, @@ -1314,7 +1320,8 @@ class PlexClient if (metadata is! Map) continue; final type = metadata['type'] as String?; - if (type != 'movie' && type != 'show') continue; + const allowedTypes = {'movie', 'show', 'artist', 'album', 'track'}; + if (!allowedTypes.contains(type)) continue; results.add(_createTaggedMetadata(metadata)); } catch (e) { @@ -1574,15 +1581,7 @@ class PlexClient Future _fetchFileInfo(String ratingKey) async { try { - final data = await fetchWithCacheFirst>( - cacheKey: '/library/metadata/$ratingKey', - networkCall: () => - _http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), - parseCache: (cached) => cached as Map?, - parseResponse: (response) => response.data as Map?, - ); - final metadataJson = _getFirstMetadataJsonFromData(data); - + final metadataJson = await _fetchRawMetadataJsonCacheFirst(ratingKey); return parsePlexFileInfoFromJson(metadataJson); } catch (e) { appLogger.e('Failed to get file info: $e'); @@ -1590,6 +1589,25 @@ class PlexClient } } + /// Cache-first raw metadata JSON for [ratingKey]. Serves the shared + /// `/library/metadata/{id}` cache row when a detail/playback flow already + /// warmed it (the common case — no extra round-trip); on a miss it fetches + /// with the full playback query params so the row it caches stays complete + /// for the cache-only readers ([fetchPlaybackExtrasFromCacheOnly], + /// [fetchCachedMediaSourceInfo]). + Future?> _fetchRawMetadataJsonCacheFirst(String ratingKey) async { + final data = await fetchWithCacheFirst>( + cacheKey: '/library/metadata/$ratingKey', + networkCall: () => _http.get( + '/library/metadata/$ratingKey', + queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1}, + ), + parseCache: (cached) => cached as Map?, + parseResponse: (response) => response.data as Map?, + ); + return _getFirstMetadataJsonFromData(data); + } + /// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`). /// /// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or @@ -1780,7 +1798,19 @@ class PlexClient ]; } try { - final response = await _getWithFailover('/library/sections/$sectionId/sorts'); + // Music sections serve per-type sort lists: the bare endpoint returns + // the section default (artist) sorts; `?type=9|10` returns album/track + // sorts. Video libraries keep the bare call — their section type + // already pins the list. + final musicType = switch (libraryType?.toLowerCase()) { + 'album' => PlexMetadataType.album, + 'track' => PlexMetadataType.track, + _ => null, + }; + final response = await _getWithFailover( + '/library/sections/$sectionId/sorts', + queryParameters: musicType == null ? null : {'type': musicType}, + ); final sorts = _extractDirectoryList(response, MediaSort.fromJson); // Fallback: return common sort options if API doesn't provide them @@ -2115,19 +2145,25 @@ class PlexClient return createPlaylistFromUri(title: title); } final uri = await buildMetadataUri(items.map((i) => i.id).join(',')); - return createPlaylistFromUri(title: title, uri: uri); + return createPlaylistFromUri(title: title, uri: uri, type: items.first.kind.isMusic ? 'audio' : 'video'); } /// Create a new playlist /// [title] - Name of the playlist /// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234") /// [playQueueId] - Optional play queue ID to create playlist from + /// [type] - Plex playlist type ('video' or 'audio' for music items) /// /// Errors propagate to the caller (matches the [MediaServerClient] /// contract — throw on HTTP/transport failures, return `null` only when /// the server replied 2xx but with no usable playlist payload). - Future createPlaylistFromUri({required String title, String? uri, int? playQueueId}) async { - final queryParams = {'type': 'video', 'title': title, 'smart': '0'}; + Future createPlaylistFromUri({ + required String title, + String? uri, + int? playQueueId, + String type = 'video', + }) async { + final queryParams = {'type': type, 'title': title, 'smart': '0'}; if (uri != null) { queryParams['uri'] = uri; @@ -3009,53 +3045,114 @@ class PlexClient selectedSubtitleTrack: selectedSubtitleTrack, offsetMs: offsetMs, ); - - final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); - - final decisionClient = MediaServerHttpClient( - connectTimeout: MediaServerTimeouts.connect, - receiveTimeout: MediaServerTimeouts.receive, - defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'}, + return await _runTranscodeDecision( + startEndpoint: _videoTranscodeStartEndpoint, + allParams: allParams, + isOriginal: preset.isOriginal, ); - try { - final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; - final decisionResponse = await decisionClient.get(decisionUrl); - - final decisionBody = decisionResponse.data?.toString() ?? ''; - appLogger.i( - 'Transcode decision [${decisionResponse.statusCode}] body: ' - '${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}', - ); - - if (decisionResponse.statusCode != 200) { - appLogger.w('Transcode decision returned ${decisionResponse.statusCode}'); - return (startPath: null, outcome: TranscodeDecisionOutcome.failed); - } - - final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: preset.isOriginal); - if (outcome == TranscodeDecisionOutcome.failed) { - return (startPath: null, outcome: outcome); - } - - return (startPath: _buildTranscodeStartPathFromParams(allParams), outcome: outcome); - } finally { - decisionClient.close(); - } } catch (e, st) { appLogger.e('Failed to build transcode start path', error: e, stackTrace: st); return (startPath: null, outcome: TranscodeDecisionOutcome.failed); } } - String _buildTranscodeStartPathFromParams(Map 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 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() ?? ''; + 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 params, { + String endpoint = _videoTranscodeStartEndpoint, + }) { final startParams = Map.from(params)..remove('X-Plex-Token'); final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); - return '/video/:/transcode/universal/start?$startQuery'; + return '$endpoint?$startQuery'; } @visibleForTesting - String buildTranscodeStartPathFromParamsForTesting(Map params) { - return _buildTranscodeStartPathFromParams(params); + String buildTranscodeStartPathFromParamsForTesting( + Map params, { + String endpoint = _videoTranscodeStartEndpoint, + }) { + return _buildTranscodeStartPathFromParams(params, endpoint: endpoint); } Map _buildTranscodeParams({ @@ -3189,6 +3286,63 @@ class PlexClient ); } + Map _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 { + '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 buildMusicTranscodeParamsForTesting({ + required String ratingKey, + required int mediaIndex, + int partIndex = 0, + required AudioQualityPreset preset, + required String sessionIdentifier, + required String transcodeSessionId, + }) { + return _buildMusicTranscodeParams( + ratingKey: ratingKey, + mediaIndex: mediaIndex, + partIndex: partIndex, + preset: preset, + sessionIdentifier: sessionIdentifier, + transcodeSessionId: transcodeSessionId, + ); + } + /// Platform name Plex Media Server accepts on the transcode decision /// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400, /// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected. @@ -3367,12 +3521,34 @@ class PlexClient @override Future> fetchPlayableDescendants(String parentId) async { + // Albums parent their tracks directly and `/grandchildren` returns + // nothing for them — branch to `/children`. Artists and shows/seasons + // one-shot via `/grandchildren` (artist → every track, show/season → + // every episode). The kind lookup rides the cached metadata row + // (cache-first — detail screens pre-warm it), so the common path adds + // no extra round-trip. + if (await _fetchItemKind(parentId) == MediaKind.album) { + return fetchChildren(parentId); + } final leaves = await _fetchAllPages( (start, size, abort) => _getGrandchildrenPage(parentId, start: start, size: size, abort: abort), ); return leaves.map((m) => PlexMappers.mediaItem(m)).toList(); } + /// Item kind for [ratingKey] via the cache-first metadata row. Returns + /// [MediaKind.unknown] when the item can't be resolved so callers fall + /// back to their default branch. + Future _fetchItemKind(String ratingKey) async { + try { + final metadataJson = await _fetchRawMetadataJsonCacheFirst(ratingKey); + return MediaKind.fromString(metadataJson?['type'] as String?); + } catch (e) { + appLogger.w('Failed to resolve item kind for $ratingKey', error: e); + return MediaKind.unknown; + } + } + @override Future> fetchPlayableDescendantsPage( String parentId, { @@ -3394,6 +3570,76 @@ class PlexClient @override Future?> 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> fetchArtistAlbums(String artistId) => fetchChildren(artistId); + + @override + Future> 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> 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 []; + 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 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? metadataJson) { + final mediaList = metadataJson?['Media']; + if (mediaList is! List) return null; + String? fallbackKey; + for (final media in mediaList) { + if (media is! Map) continue; + final parts = media['Part']; + if (parts is! List) continue; + for (final part in parts) { + if (part is! Map) continue; + final streams = part['Stream']; + if (streams is! List) continue; + for (final stream in streams) { + if (stream is! Map) continue; + if (flexibleInt(stream['streamType']) != PlexStreamType.lyrics) continue; + final key = stream['key'] as String?; + if (key == null || key.isEmpty) continue; + final format = ((stream['format'] ?? stream['codec']) as String?)?.toLowerCase(); + if (format == 'lrc') return key; + fallbackKey ??= key; + } + } + } + return fallbackKey; + } + /// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata, /// then either runs the transcode-decision flow or returns the direct-play /// URL. External subtitle tracks are absolutized with the server's auth @@ -3414,8 +3660,39 @@ class PlexClient throw PlaybackException(t.messages.fileInfoNotAvailable); } - final wantTranscode = !options.qualityPreset.isOriginal; + // Tracks consult the music preset — [qualityPreset] is video-shaped + // (resolution/videoQuality) and is ignored for audio. + final isTrack = options.metadata.kind == MediaKind.track; + final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original; + final wantTranscode = isTrack ? !audioPreset.isOriginal : !options.qualityPreset.isOriginal; if (wantTranscode && options.sessionIdentifier != null && options.transcodeSessionId != null) { + if (isTrack) { + final result = await buildMusicTranscodeStartPath( + ratingKey: options.metadata.id, + mediaIndex: data.selectedMediaIndex, + partIndex: data.selectedPartIndex, + preset: audioPreset, + sessionIdentifier: options.sessionIdentifier!, + transcodeSessionId: options.transcodeSessionId!, + ); + + if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) { + final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token); + return PlaybackInitializationResult( + availableVersions: data.availableVersions, + videoUrl: transcodeUrl, + mediaInfo: data.mediaInfo, + isOffline: false, + isTranscoding: true, + playMethod: 'Transcode', + playSessionId: options.sessionIdentifier, + selectedMediaIndex: data.selectedMediaIndex, + ); + } + + return _transcodeFallbackResult(data, result.outcome, options); + } + final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo); final resumeOffsetMs = options.metadata.viewOffsetMs; final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo); @@ -3448,24 +3725,7 @@ class PlexClient ); } - // Decision failed or said direct-play only — fall through to direct-play path - // and surface the fallback reason so the UI can notify the user. - final fallbackReason = result.outcome == TranscodeDecisionOutcome.directPlayOnly - ? TranscodeFallbackReason.directPlayOnly - : TranscodeFallbackReason.decisionFailed; - appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}'); - return PlaybackInitializationResult( - availableVersions: data.availableVersions, - videoUrl: data.videoUrl, - mediaInfo: data.mediaInfo, - externalSubtitles: _buildExternalSubtitles(data.mediaInfo), - isOffline: false, - isTranscoding: false, - fallbackReason: fallbackReason, - playMethod: 'DirectPlay', - playSessionId: options.sessionIdentifier, - selectedMediaIndex: data.selectedMediaIndex, - ); + return _transcodeFallbackResult(data, result.outcome, options); } return PlaybackInitializationResult( @@ -3484,6 +3744,33 @@ class PlexClient } } + /// Direct-play result for a transcode decision that fell back (failed or + /// said direct-play only), surfacing the reason so the UI can notify the + /// user. Shared by the video and music branches of + /// [getPlaybackInitialization]. + PlaybackInitializationResult _transcodeFallbackResult( + PlexVideoPlaybackData data, + TranscodeDecisionOutcome outcome, + PlaybackInitializationOptions options, + ) { + final fallbackReason = outcome == TranscodeDecisionOutcome.directPlayOnly + ? TranscodeFallbackReason.directPlayOnly + : TranscodeFallbackReason.decisionFailed; + appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}'); + return PlaybackInitializationResult( + availableVersions: data.availableVersions, + videoUrl: data.videoUrl, + mediaInfo: data.mediaInfo, + externalSubtitles: _buildExternalSubtitles(data.mediaInfo), + isOffline: false, + isTranscoding: false, + fallbackReason: fallbackReason, + playMethod: 'DirectPlay', + playSessionId: options.sessionIdentifier, + selectedMediaIndex: data.selectedMediaIndex, + ); + } + /// Pick the audio stream ID to send to the transcoder. Preference order: /// explicit [explicit] → audio track with `selected == true` → first → null. int? _resolveAudioStreamId(int? explicit, MediaSourceInfo? info) { diff --git a/lib/services/plex_constants.dart b/lib/services/plex_constants.dart index 73cdd27e..88521f33 100644 --- a/lib/services/plex_constants.dart +++ b/lib/services/plex_constants.dart @@ -8,6 +8,7 @@ class PlexStreamType { static const int video = 1; static const int audio = 2; static const int subtitle = 3; + static const int lyrics = 4; } /// Plex metadata `type` integer codes — the value that goes in the diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index ce704520..a50dae2b 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -15,6 +15,7 @@ import 'base_shared_preferences_service.dart'; import 'device_performance.dart'; export 'base_shared_preferences_service.dart' show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref; +import '../models/audio_quality_preset.dart'; import '../models/transcode_quality_preset.dart'; import '../navigation/navigation_tabs.dart'; import '../utils/platform_detector.dart'; @@ -420,6 +421,11 @@ class SettingsService extends BaseSharedPreferencesService { values: TranscodeQualityPreset.values, defaultValue: TranscodeQualityPreset.original, ); + static const musicQualityPreset = EnumPref( + 'music_quality_preset', + values: AudioQualityPreset.values, + defaultValue: AudioQualityPreset.original, + ); static const autoPlayNextEpisode = BoolPref('auto_play_next_episode', defaultValue: true); static const useExoPlayer = BoolPref('use_exoplayer', defaultValue: true); static const startupSection = EnumPref( diff --git a/lib/utils/lrc_parser.dart b/lib/utils/lrc_parser.dart new file mode 100644 index 00000000..3f5b3094 --- /dev/null +++ b/lib/utils/lrc_parser.dart @@ -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 = []; + final plain = []; + 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, +}; diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index 64211858..6becf38d 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -188,8 +188,13 @@ void main() { expect(results.map((item) => item.id), ['jf-show']); expect(plexRequests.single.queryParameters['limit'], '100'); - expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv'); - expect(jellyfinRequests.single.queryParameters['Limit'], '100'); + expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv,music'); + // Jellyfin search fans out to /Items plus a best-effort /Artists call + // (500 above → treated as empty). + final jfItemsRequest = jellyfinRequests.singleWhere((url) => url.path == '/Items'); + expect(jfItemsRequest.queryParameters['Limit'], '100'); + final jfArtistsRequest = jellyfinRequests.singleWhere((url) => url.path == '/Artists'); + expect(jfArtistsRequest.queryParameters['searchTerm'], 'The Boys'); }); test('getOnDeckFromAllServers forwards preview limit to clients', () async { diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index d115d446..505430c5 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -90,6 +90,26 @@ void main() { expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/stream'); }); + test('buildAudioDirectStreamUrl targets /Audio with the same static-stream contract', () { + final url = client.buildAudioDirectStreamUrl('track-7'); + final uri = Uri.parse(url); + + expect(uri.path, '/Audio/track-7/stream'); + expect(uri.queryParameters['Static'], 'true'); + expect(uri.queryParameters['api_key'], 'tok-abc'); + expect(uri.queryParameters['DeviceId'], 'dev-xyz'); + expect(uri.queryParameters.containsKey('Container'), isFalse); + expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse); + }); + + test('buildAudioDirectStreamUrl appends Container and MediaSourceId when provided', () { + final url = client.buildAudioDirectStreamUrl('track-7', container: 'flac', mediaSourceId: 'src-9'); + final uri = Uri.parse(url); + + expect(uri.queryParameters['Container'], 'flac'); + expect(uri.queryParameters['MediaSourceId'], 'src-9'); + }); + test('buildDirectStreamUrl canonicalizes a mixed-case scheme from stored config', () async { // This URL bypasses Dart's Uri normalization on its way to the player, // and FFmpeg's protocol lookup is case-sensitive — a stored @@ -2952,7 +2972,8 @@ void main() { expect(requestUri, isNotNull); expect(requestUri!.queryParameters['ParentId'], 'show-1'); expect(requestUri!.queryParameters['Recursive'], 'true'); - expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode'); + // Audio rides along so albums/artists/audio playlists expand to tracks. + expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Audio'); expect(requestUri!.queryParameters['StartIndex'], '20'); expect(requestUri!.queryParameters['Limit'], '10'); }); diff --git a/test/services/jellyfin_music_mapper_test.dart b/test/services/jellyfin_music_mapper_test.dart new file mode 100644 index 00000000..5e85a14c --- /dev/null +++ b/test/services/jellyfin_music_mapper_test.dart @@ -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 _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': [], + 'MediaType': 'Audio', +}; + +/// Captured (trimmed) `MusicAlbum` row from the same server. +Map _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'] = {}; + + 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'] = {} + ..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 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': {}, + '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); + }); + }); +} diff --git a/test/services/plex_music_transcode_test.dart b/test/services/plex_music_transcode_test.dart new file mode 100644 index 00000000..841d1b37 --- /dev/null +++ b/test/services/plex_music_transcode_test.dart @@ -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 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'))); + }); +} diff --git a/test/services/plex_search_test.dart b/test/services/plex_search_test.dart index f35ab547..303e00ad 100644 --- a/test/services/plex_search_test.dart +++ b/test/services/plex_search_test.dart @@ -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 = []; final client = makeClient((request) async { captured.add(request.url); @@ -66,6 +66,6 @@ void main() { expect(captured.single.path, '/library/search'); expect(captured.single.queryParameters['limit'], '100'); expect(captured.single.queryParameters['X-Plex-Container-Size'], '100'); - expect(captured.single.queryParameters['searchTypes'], 'movies,tv'); + expect(captured.single.queryParameters['searchTypes'], 'movies,tv,music'); }); } diff --git a/test/utils/lrc_parser_test.dart b/test/utils/lrc_parser_test.dart new file mode 100644 index 00000000..167f6041 --- /dev/null +++ b/test/utils/lrc_parser_test.dart @@ -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); + }); + }); +}