From 0f5e5c8b6ed8a9795fe22a3688ada60848d5038c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:30:12 +0200 Subject: [PATCH] feat(music): offer File Info on tracks and other file-backed items The context menu only offered File Info for movies and episodes, so a track's path, container, and audio stream detail were unreachable even though both backends already answer getFileInfo for them. Gate the entry on the new MediaKind.hasFileInfo instead of a literal kind list: movies, episodes, tracks, and clips are leaf items with real files, while shows, seasons, artists, albums, collections, playlists, and folders carry no Media/MediaSources and would only ever produce the "not available" snackbar. Also fix the Plex stream classifier, which mapped streamType 4 to an embedded image although PlexStreamType.lyrics is 4. Only music tracks carry that type, so a track's lyric stream rendered under "Embedded Images" with the video field block. Type 5 was invented outright and is now unknown. close #1747 --- lib/media/media_kind.dart | 9 ++ lib/services/file_info_parser.dart | 11 +- lib/widgets/media_context_menu.dart | 15 +- .../media_file_info_parsers_test.dart | 130 ++++++++++++++++++ test/widgets/file_info_bottom_sheet_test.dart | 46 +++++++ test/widgets/media_context_menu_test.dart | 76 ++++++++++ 6 files changed, 277 insertions(+), 10 deletions(-) diff --git a/lib/media/media_kind.dart b/lib/media/media_kind.dart index 8015e816..c4e8501f 100644 --- a/lib/media/media_kind.dart +++ b/lib/media/media_kind.dart @@ -30,6 +30,15 @@ enum MediaKind { bool get isPlayable => isVideo || this == track; + /// Whether this kind maps to one or more files on disk, i.e. the server can + /// answer `MediaServerClient.getFileInfo` for it. + /// + /// Deliberately not folded into [isPlayable] even though the member sets + /// coincide today: container kinds (show, season, artist, album, collection, + /// playlist, folder) aggregate leaves and carry no `Media`/`MediaSources` of + /// their own, while a non-playable kind can still be file-backed. + bool get hasFileInfo => this == movie || this == episode || this == track || this == clip; + /// Whether this container kind derives watched state from aggregate leaves. bool get usesLeafWatchCounts => switch (this) { show || season || artist || album || collection || playlist || folder => true, diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart index 8c7e5e2a..93b186d6 100644 --- a/lib/services/file_info_parser.dart +++ b/lib/services/file_info_parser.dart @@ -253,14 +253,17 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader { // readers above do: one place to compare when a server adds a key. // --------------------------------------------------------------------------- -/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 1–5; -/// lyrics/other types beyond that map to [MediaStreamKind.unknown]. +/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 1–4 +/// ([PlexStreamType]); anything else maps to [MediaStreamKind.unknown]. +/// +/// Type 4 is lyrics, not an embedded image — music tracks are the only items +/// that carry it, so the mismatch only became visible once File Info was +/// offered for tracks. MediaStreamKind plexStreamKind(Map s) => switch (flexibleInt(s['streamType'])) { PlexStreamType.video => MediaStreamKind.video, PlexStreamType.audio => MediaStreamKind.audio, PlexStreamType.subtitle => MediaStreamKind.subtitle, - 4 => MediaStreamKind.image, - 5 => MediaStreamKind.data, + PlexStreamType.lyrics => MediaStreamKind.lyric, _ => MediaStreamKind.unknown, }; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 1251eda8..c1e5ebe8 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -546,12 +546,15 @@ class MediaContextMenuState extends State { ); } - // File Info (for episodes and movies). Backend-neutral — both - // PlexClient and JellyfinClient implement [getFileInfo], reading - // codec/stream metadata from `Media`/`MediaSources` respectively. - // Hidden when the item has no backend marker so we don't fan out - // to an arbitrary client. - if (itemBackend != null && (mediaKind == MediaKind.episode || mediaKind == MediaKind.movie)) { + // File Info — every file-backed leaf kind (movies, episodes, tracks, + // clips). Backend-neutral: both PlexClient and JellyfinClient implement + // [MediaServerClient.getFileInfo], reading codec/stream metadata from + // `Media`/`MediaSources` respectively. Container kinds are excluded by + // [MediaKind.hasFileInfo] because neither backend attaches media sources + // to them — a show/season/album/artist entry would only ever produce the + // "not available" snackbar. Hidden when the item has no backend marker + // so we don't fan out to an arbitrary client. + if (itemBackend != null && mediaKind != null && mediaKind.hasFileInfo) { menuActions.add(_MenuAction(value: 'fileinfo', icon: Symbols.info_rounded, label: t.mediaMenu.fileInfo)); } diff --git a/test/services/media_file_info_parsers_test.dart b/test/services/media_file_info_parsers_test.dart index 9149ce36..36a42599 100644 --- a/test/services/media_file_info_parsers_test.dart +++ b/test/services/media_file_info_parsers_test.dart @@ -266,6 +266,86 @@ void main() { expect(streams.map((stream) => stream.codec), ['h264', 'aac']); }); + test('projects an audio-only track without inventing video fields', () { + final result = parsePlexFileInfoFromJson({ + 'Media': [ + { + 'id': 42, + 'container': 'flac', + 'bitrate': 989, + 'duration': 201000, + 'audioCodec': 'flac', + 'audioChannels': 2, + 'Part': [ + { + 'id': 'part-1', + 'file': '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac', + 'size': 35651584, + 'container': 'flac', + 'Stream': [ + { + 'id': '201', + 'streamType': 2, + 'codec': 'flac', + 'channels': 2, + 'audioChannelLayout': 'stereo', + 'samplingRate': 44100, + 'bitDepth': 16, + }, + ], + }, + ], + }, + ], + }); + + final version = result!.versions.single; + expect(version.container, 'flac'); + expect(version.audioCodec, 'flac'); + expect(version.audioChannels, 2); + expect(version.videoCodec, isNull); + expect(version.videoResolutionLabel, isNull); + expect(version.resolutionFormatted, isNull); + expect(version.aspectRatioFormatted, isNull); + + final part = version.parts.single; + expect(part.filePath, '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac'); + expect(part.fileSize, 35651584); + expect(part.streamsOfKind(MediaStreamKind.video), isEmpty); + + final audio = part.streamsOfKind(MediaStreamKind.audio).single; + expect(audio.codec, 'flac'); + expect(audio.channelsFormatted, 'stereo (2 ch)'); + expect(audio.sampleRateFormatted, '44.1 kHz'); + expect(audio.bitDepthFormatted, '16 bit'); + }); + + test('classifies streamType 4 as lyrics rather than an embedded image', () { + // Stream shapes copied from a live Plex music library: a sidecar LRC + // arrives as streamType 4 with a `format` and a `/library/streams/{id}` + // key, which the sheet renders as the Lyrics group. + final streams = _plexStreams([ + {'id': '52586', 'streamType': 2, 'codec': 'flac'}, + { + 'id': '52594', + 'streamType': 4, + 'codec': 'lrc', + 'format': 'lrc', + 'key': '/library/streams/52594', + 'displayTitle': 'LRC', + }, + {'id': '203', 'streamType': 9, 'codec': 'mystery'}, + ]); + + expect(streams.map((stream) => stream.kind), [ + MediaStreamKind.audio, + MediaStreamKind.lyric, + MediaStreamKind.unknown, + ]); + expect(streams[1].codec, 'lrc'); + expect(streams[1].externalKey, '/library/streams/52594'); + }); + test('returns null for null metadata or metadata without Media', () { expect(parsePlexFileInfoFromJson(null), isNull); expect(parsePlexFileInfoFromJson(const {}), isNull); @@ -459,6 +539,56 @@ void main() { expect(version.attachments.last.codec, 'explicit-codec'); }); + test('projects an audio-only media source without inventing video fields', () { + final result = parseJellyfinFileInfoFromJson({ + 'MediaSources': [ + { + 'Id': 'source-1', + 'Name': 'Ready Lets Go', + 'Container': 'flac', + 'Path': '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac', + 'Size': 35651584, + 'Bitrate': 989000, + 'RunTimeTicks': 2010000000, + 'MediaStreams': [ + { + 'Type': 'Audio', + 'Codec': 'flac', + 'Channels': 2, + 'ChannelLayout': 'stereo', + 'SampleRate': 44100, + 'BitDepth': 16, + }, + {'Type': 'EmbeddedImage', 'Codec': 'mjpeg'}, + {'Type': 'Lyric', 'Codec': 'lrc', 'Path': '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.lrc'}, + ], + }, + ], + }); + + final version = result!.versions.single; + expect(version.container, 'flac'); + expect(version.audioCodec, 'flac'); + expect(version.videoCodec, isNull); + expect(version.videoResolutionLabel, isNull); + expect(version.resolutionFormatted, isNull); + expect(version.aspectRatioFormatted, isNull); + + final part = version.parts.single; + expect(part.filePath, '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac'); + expect(part.streamsOfKind(MediaStreamKind.video), isEmpty); + + final audio = part.streamsOfKind(MediaStreamKind.audio).single; + expect(audio.channelsFormatted, 'stereo (2 ch)'); + expect(audio.sampleRateFormatted, '44.1 kHz'); + expect(audio.bitDepthFormatted, '16 bit'); + + expect(part.streamsOfKind(MediaStreamKind.image).single.codec, 'mjpeg'); + final lyric = part.streamsOfKind(MediaStreamKind.lyric).single; + expect(lyric.codec, 'lrc'); + expect(lyric.filePath, '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.lrc'); + }); + test('returns null for missing empty or non-list MediaSources', () { expect(parseJellyfinFileInfoFromJson(const {}), isNull); expect(parseJellyfinFileInfoFromJson(const {'MediaSources': []}), isNull); diff --git a/test/widgets/file_info_bottom_sheet_test.dart b/test/widgets/file_info_bottom_sheet_test.dart index dbe26a16..b98033bb 100644 --- a/test/widgets/file_info_bottom_sheet_test.dart +++ b/test/widgets/file_info_bottom_sheet_test.dart @@ -42,6 +42,52 @@ void main() { expect(find.text('Subtitles'), findsOneWidget); }); + testWidgets('audio-only file drops the video section and keeps the copyable path', (tester) async { + const path = '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac'; + const fileInfo = MediaFileInfo( + versions: [ + MediaFileVersion( + container: 'flac', + parts: [ + MediaFilePart( + filePath: path, + fileSize: 35651584, + streams: [ + MediaStreamDetails( + kind: MediaStreamKind.audio, + ordinal: 1, + codec: 'flac', + channels: 2, + channelLayout: 'stereo', + sampleRate: 44100, + bitDepth: 16, + ), + MediaStreamDetails(kind: MediaStreamKind.lyric, ordinal: 1, codec: 'lrc'), + ], + ), + ], + ), + ], + ); + + await _pumpSheet(tester, fileInfo: fileInfo, title: 'Ready Lets Go'); + + expect(find.text('Ready Lets Go'), findsOneWidget); + expect(find.text('File'), findsOneWidget); + expect(find.text('Audio'), findsOneWidget); + expect(find.text('Lyrics'), findsOneWidget); + expect(find.text('stereo (2 ch)'), findsOneWidget); + expect(find.text('44.1 kHz'), findsOneWidget); + expect(find.text('16 bit'), findsOneWidget); + expect(find.text(path), findsOneWidget); + + // Nothing video-shaped may leak into a track's sheet. + expect(find.text('Video'), findsNothing); + expect(find.text('Embedded Images'), findsNothing); + expect(find.text('Resolution'), findsNothing); + expect(find.text('Frame Rate'), findsNothing); + }); + testWidgets('absent fields and empty sections are omitted instead of rendering blank labels', (tester) async { const fileInfo = MediaFileInfo( versions: [ diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index a670b882..a2fe2927 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -15,6 +15,7 @@ import 'package:plezy/navigation/profile_navigation_scope.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_file_info.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_playlist.dart'; @@ -43,6 +44,7 @@ import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_timeouts.dart'; import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/file_info_bottom_sheet.dart'; import 'package:plezy/widgets/media_context_menu.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; @@ -692,6 +694,69 @@ void main() { expect(harness.music.playedTracks, [newerTrack]); expect(tester.takeException(), isNull); }); + + testWidgets('track file info fetches the tapped track and renders its audio-only sheet', (tester) async { + const trackPath = '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac'; + final track = testMediaItem( + id: 'track-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.track, + title: 'Ready Lets Go', + parentId: 'album-1', + parentTitle: 'Geogaddi', + grandparentId: 'artist-1', + grandparentTitle: 'Boards of Canada', + serverId: 'srv-1', + ); + final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: const []); + harness.client.fileInfo = const MediaFileInfo( + versions: [ + MediaFileVersion( + container: 'flac', + parts: [ + MediaFilePart( + filePath: trackPath, + fileSize: 35651584, + streams: [MediaStreamDetails(kind: MediaStreamKind.audio, ordinal: 1, codec: 'flac', channels: 2)], + ), + ], + ), + ], + ); + + await _selectSiblingMusicMenuAction(tester, harness, t.mediaMenu.fileInfo); + + expect(harness.client.fileInfoRequests, [same(track)]); + expect(find.byType(FileInfoBottomSheet), findsOneWidget); + expect(find.text('Ready Lets Go'), findsOneWidget); + expect(find.text(trackPath), findsOneWidget); + expect(find.text(t.fileInfo.audio), findsOneWidget); + expect(find.text(t.fileInfo.video), findsNothing); + // The loading dialog must be gone and no error snackbar raised. + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(SnackBar), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('album menu omits file info because containers carry no media sources', (tester) async { + final album = testMediaItem( + id: 'album-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.album, + title: 'Geogaddi', + parentId: 'artist-1', + parentTitle: 'Boards of Canada', + serverId: 'srv-1', + ); + final harness = await _pumpSiblingMusicMenu(tester, item: album, relatedItems: const []); + + harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target'))); + await tester.pumpAndSettle(); + + expect(find.text(t.music.playNext), findsOneWidget); + expect(find.text(t.mediaMenu.fileInfo), findsNothing); + expect(harness.client.fileInfoRequests, isEmpty); + }); }); } @@ -977,6 +1042,17 @@ class _RelatedMusicClient implements MediaServerClient { @override Future> fetchArtistAlbums(MediaItem artist) async => const []; + /// Items the menu asked file info for, in call order — the track menu must + /// resolve the client for the tapped item, not for whatever is playing. + final List fileInfoRequests = []; + MediaFileInfo? fileInfo; + + @override + Future getFileInfo(MediaItem item) async { + fileInfoRequests.add(item); + return fileInfo; + } + @override void close() {}