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
This commit is contained in:
edde746
2026-08-03 02:34:37 +02:00
parent a9f0532f5f
commit 0f5e5c8b6e
6 changed files with 277 additions and 10 deletions
+9
View File
@@ -30,6 +30,15 @@ enum MediaKind {
bool get isPlayable => isVideo || this == track; 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. /// Whether this container kind derives watched state from aggregate leaves.
bool get usesLeafWatchCounts => switch (this) { bool get usesLeafWatchCounts => switch (this) {
show || season || artist || album || collection || playlist || folder => true, show || season || artist || album || collection || playlist || folder => true,
+7 -4
View File
@@ -253,14 +253,17 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader {
// readers above do: one place to compare when a server adds a key. // readers above do: one place to compare when a server adds a key.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 15; /// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 14
/// lyrics/other types beyond that map to [MediaStreamKind.unknown]. /// ([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<String, dynamic> s) => switch (flexibleInt(s['streamType'])) { MediaStreamKind plexStreamKind(Map<String, dynamic> s) => switch (flexibleInt(s['streamType'])) {
PlexStreamType.video => MediaStreamKind.video, PlexStreamType.video => MediaStreamKind.video,
PlexStreamType.audio => MediaStreamKind.audio, PlexStreamType.audio => MediaStreamKind.audio,
PlexStreamType.subtitle => MediaStreamKind.subtitle, PlexStreamType.subtitle => MediaStreamKind.subtitle,
4 => MediaStreamKind.image, PlexStreamType.lyrics => MediaStreamKind.lyric,
5 => MediaStreamKind.data,
_ => MediaStreamKind.unknown, _ => MediaStreamKind.unknown,
}; };
+9 -6
View File
@@ -546,12 +546,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
); );
} }
// File Info (for episodes and movies). Backend-neutral — both // File Info — every file-backed leaf kind (movies, episodes, tracks,
// PlexClient and JellyfinClient implement [getFileInfo], reading // clips). Backend-neutral: both PlexClient and JellyfinClient implement
// codec/stream metadata from `Media`/`MediaSources` respectively. // [MediaServerClient.getFileInfo], reading codec/stream metadata from
// Hidden when the item has no backend marker so we don't fan out // `Media`/`MediaSources` respectively. Container kinds are excluded by
// to an arbitrary client. // [MediaKind.hasFileInfo] because neither backend attaches media sources
if (itemBackend != null && (mediaKind == MediaKind.episode || mediaKind == MediaKind.movie)) { // 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)); menuActions.add(_MenuAction(value: 'fileinfo', icon: Symbols.info_rounded, label: t.mediaMenu.fileInfo));
} }
@@ -266,6 +266,86 @@ void main() {
expect(streams.map((stream) => stream.codec), ['h264', 'aac']); 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', () { test('returns null for null metadata or metadata without Media', () {
expect(parsePlexFileInfoFromJson(null), isNull); expect(parsePlexFileInfoFromJson(null), isNull);
expect(parsePlexFileInfoFromJson(const {}), isNull); expect(parsePlexFileInfoFromJson(const {}), isNull);
@@ -459,6 +539,56 @@ void main() {
expect(version.attachments.last.codec, 'explicit-codec'); 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', () { test('returns null for missing empty or non-list MediaSources', () {
expect(parseJellyfinFileInfoFromJson(const {}), isNull); expect(parseJellyfinFileInfoFromJson(const {}), isNull);
expect(parseJellyfinFileInfoFromJson(const {'MediaSources': []}), isNull); expect(parseJellyfinFileInfoFromJson(const {'MediaSources': []}), isNull);
@@ -42,6 +42,52 @@ void main() {
expect(find.text('Subtitles'), findsOneWidget); 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 { testWidgets('absent fields and empty sections are omitted instead of rendering blank labels', (tester) async {
const fileInfo = MediaFileInfo( const fileInfo = MediaFileInfo(
versions: [ versions: [
+76
View File
@@ -15,6 +15,7 @@ import 'package:plezy/navigation/profile_navigation_scope.dart';
import 'package:plezy/media/ids.dart'; import 'package:plezy/media/ids.dart';
import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.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_item.dart';
import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_playlist.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_http_client.dart';
import 'package:plezy/utils/media_server_timeouts.dart'; import 'package:plezy/utils/media_server_timeouts.dart';
import 'package:plezy/utils/platform_detector.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:plezy/widgets/media_context_menu.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/backend_client_fixtures.dart';
@@ -692,6 +694,69 @@ void main() {
expect(harness.music.playedTracks, [newerTrack]); expect(harness.music.playedTracks, [newerTrack]);
expect(tester.takeException(), isNull); 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 @override
Future<List<MediaItem>> fetchArtistAlbums(MediaItem artist) async => const []; Future<List<MediaItem>> 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<MediaItem> fileInfoRequests = [];
MediaFileInfo? fileInfo;
@override
Future<MediaFileInfo?> getFileInfo(MediaItem item) async {
fileInfoRequests.add(item);
return fileInfo;
}
@override @override
void close() {} void close() {}