feat(jellyfin): add folder browsing and playback
This commit is contained in:
@@ -1529,6 +1529,75 @@ void main() {
|
||||
expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode');
|
||||
});
|
||||
|
||||
test('fetchLibraryFolders uses direct non-recursive Items query and orders folders first', () async {
|
||||
Uri? captured;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
captured = req.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
{'Id': 'track-z', 'Type': 'Audio', 'Name': 'Z Track', 'IsFolder': false},
|
||||
{'Id': 'series-a', 'Type': 'Series', 'Name': 'A Show', 'IsFolder': true},
|
||||
{'Id': 'folder-z', 'Type': 'Folder', 'Name': 'Z Folder', 'IsFolder': true},
|
||||
{'Id': 'movie-m', 'Type': 'Movie', 'Name': 'Movie', 'IsFolder': false},
|
||||
],
|
||||
'TotalRecordCount': 4,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final items = await scoped.fetchLibraryFolders('lib-1');
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(captured!.path, '/Items');
|
||||
expect(captured!.queryParameters['ParentId'], 'lib-1');
|
||||
expect(captured!.queryParameters['Recursive'], 'false');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['SortBy'], 'IsFolder,SortName');
|
||||
expect(captured!.queryParameters['SortOrder'], 'Ascending');
|
||||
expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources')));
|
||||
expect(items.map((item) => item.id), ['folder-z', 'series-a', 'movie-m', 'track-z']);
|
||||
expect(items.first.kind, MediaKind.unknown);
|
||||
expect(items.first.raw?['IsFolder'], isTrue);
|
||||
expect(items[1].kind, MediaKind.show);
|
||||
});
|
||||
|
||||
test('fetchFolderChildren pages direct folder contents', () async {
|
||||
final starts = <String?>[];
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
starts.add(req.url.queryParameters['StartIndex']);
|
||||
final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
|
||||
const total = 501;
|
||||
final end = start == 0 ? 500 : total;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
for (var i = start; i < end; i++)
|
||||
{'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false},
|
||||
],
|
||||
'TotalRecordCount': total,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
final items = await scoped.fetchFolderChildren('folder-1');
|
||||
|
||||
expect(starts, ['0', '500']);
|
||||
expect(items, hasLength(501));
|
||||
});
|
||||
|
||||
test('fetchClientSideEpisodeQueue pages past the first 200 episodes', () async {
|
||||
final starts = <String?>[];
|
||||
final pagedClient = JellyfinClient.forTesting(
|
||||
@@ -2481,6 +2550,37 @@ void main() {
|
||||
expect(requestUri!.queryParameters['Limit'], '10');
|
||||
});
|
||||
|
||||
test('fetchPlayableFolderDescendants includes generic video but excludes audio', () async {
|
||||
Uri? requestUri;
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path == '/Items') {
|
||||
requestUri = req.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'Items': [
|
||||
{'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'},
|
||||
],
|
||||
'TotalRecordCount': 1,
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
addTearDown(client.close);
|
||||
|
||||
final items = await client.fetchPlayableFolderDescendants('folder-1');
|
||||
|
||||
expect(items.single.kind, MediaKind.clip);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.queryParameters['ParentId'], 'folder-1');
|
||||
expect(requestUri!.queryParameters['Recursive'], 'true');
|
||||
expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo');
|
||||
expect(requestUri!.queryParameters['IncludeItemTypes'], isNot(contains('Audio')));
|
||||
});
|
||||
|
||||
test('fetchChildren walks generic children pages', () async {
|
||||
final itemRequests = <Uri>[];
|
||||
final mock = MockClient((req) async {
|
||||
|
||||
@@ -94,6 +94,24 @@ void main() {
|
||||
expect(item.isWatched, isFalse);
|
||||
});
|
||||
|
||||
test('maps generic Jellyfin video types to playable clips', () {
|
||||
final video = JellyfinMappers.mediaItem(
|
||||
{'Id': 'home-video', 'Name': 'Home Video', 'Type': 'Video'},
|
||||
serverId: _serverId,
|
||||
absolutizer: null,
|
||||
)!;
|
||||
final musicVideo = JellyfinMappers.mediaItem(
|
||||
{'Id': 'music-video', 'Name': 'Music Video', 'Type': 'MusicVideo'},
|
||||
serverId: _serverId,
|
||||
absolutizer: null,
|
||||
)!;
|
||||
|
||||
expect(video.kind, MediaKind.clip);
|
||||
expect(musicVideo.kind, MediaKind.clip);
|
||||
expect(video.kind.isVideo, isTrue);
|
||||
expect(musicVideo.kind.isVideo, isTrue);
|
||||
});
|
||||
|
||||
test('episode preserves series/season hierarchy', () {
|
||||
final json = {
|
||||
'Id': 'ep1',
|
||||
|
||||
@@ -19,14 +19,17 @@ import 'package:plezy/utils/media_server_http_client.dart';
|
||||
/// `implements JellyfinClient` so existing tests stay backend-tagged.
|
||||
class _RecordingJellyfinClient implements JellyfinClient {
|
||||
final List<MediaItem> playableDescendantsResponse;
|
||||
final List<MediaItem> playableFolderDescendantsResponse;
|
||||
final List<MediaItem> seriesEpisodesResponse;
|
||||
final List<MediaItem> playlistItemsResponse;
|
||||
final List<String> fetchPlayableDescendantsCalls = [];
|
||||
final List<String> fetchPlayableFolderDescendantsCalls = [];
|
||||
final List<String> fetchSeriesEpisodesCalls = [];
|
||||
final List<({String id, int offset, int limit})> fetchPlaylistItemsCalls = [];
|
||||
|
||||
_RecordingJellyfinClient({
|
||||
this.playableDescendantsResponse = const [],
|
||||
this.playableFolderDescendantsResponse = const [],
|
||||
this.seriesEpisodesResponse = const [],
|
||||
this.playlistItemsResponse = const [],
|
||||
});
|
||||
@@ -37,6 +40,12 @@ class _RecordingJellyfinClient implements JellyfinClient {
|
||||
return playableDescendantsResponse;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableFolderDescendants(String parentId) async {
|
||||
fetchPlayableFolderDescendantsCalls.add(parentId);
|
||||
return playableFolderDescendantsResponse;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async {
|
||||
fetchSeriesEpisodesCalls.add(seriesId);
|
||||
@@ -80,6 +89,15 @@ MediaItem _ep(String id, {String? serverId = 'srv-jf'}) => MediaItem(
|
||||
serverId: serverId,
|
||||
);
|
||||
|
||||
MediaItem _movie(String id, {String? serverId = 'srv-jf'}) =>
|
||||
MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, title: 'Movie $id', serverId: serverId);
|
||||
|
||||
MediaItem _clip(String id, {String? serverId = 'srv-jf'}) =>
|
||||
MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.clip, title: 'Video $id', serverId: serverId);
|
||||
|
||||
MediaItem _track(String id, {String? serverId = 'srv-jf'}) =>
|
||||
MediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.track, title: 'Track $id', serverId: serverId);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -384,6 +402,105 @@ void main() {
|
||||
expect(navigated.single.id, 'a');
|
||||
});
|
||||
|
||||
testWidgets('folder path seeds a video-only local queue', (tester) async {
|
||||
final ctx = await pumpContext(tester);
|
||||
final fakeClient = _RecordingJellyfinClient(
|
||||
playableFolderDescendantsResponse: [_track('song'), _movie('movie', serverId: null), _clip('video')],
|
||||
);
|
||||
final playback = PlaybackStateProvider();
|
||||
final navigated = <MediaItem>[];
|
||||
|
||||
final launcher = JellyfinSequentialLauncher(
|
||||
context: ctx,
|
||||
clientForTesting: fakeClient,
|
||||
playbackStateForTesting: playback,
|
||||
navigateForTesting: (m) async => navigated.add(m),
|
||||
);
|
||||
|
||||
final folder = MediaItem(
|
||||
id: 'folder-1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.unknown,
|
||||
title: 'Folder',
|
||||
serverId: 'srv-jf',
|
||||
serverName: 'Home Jellyfin',
|
||||
libraryId: 'lib-1',
|
||||
libraryTitle: 'Videos',
|
||||
);
|
||||
|
||||
final result = await launcher.launchFromFolder(folder: folder, shuffle: false, showLoadingIndicator: false);
|
||||
|
||||
expect(result, isA<PlayQueueSuccess>());
|
||||
expect(fakeClient.fetchPlayableFolderDescendantsCalls, ['folder-1']);
|
||||
expect(fakeClient.fetchPlayableDescendantsCalls, isEmpty);
|
||||
expect(playback.loadedItems.map((m) => m.id).toList(), ['movie', 'video']);
|
||||
expect(playback.loadedItems.any((m) => m.kind == MediaKind.track), isFalse);
|
||||
expect(playback.loadedItems.first.serverId, 'srv-jf');
|
||||
expect(playback.loadedItems.first.libraryId, 'lib-1');
|
||||
expect(playback.isQueueActive, isTrue);
|
||||
expect(playback.isShuffleActive, isFalse);
|
||||
expect(navigated.single.id, 'movie');
|
||||
});
|
||||
|
||||
testWidgets('folder shuffle reorders the video queue', (tester) async {
|
||||
final ctx = await pumpContext(tester);
|
||||
final originalIds = List.generate(50, (i) => 'v$i');
|
||||
final fakeClient = _RecordingJellyfinClient(playableFolderDescendantsResponse: originalIds.map(_clip).toList());
|
||||
final playback = PlaybackStateProvider();
|
||||
|
||||
final launcher = JellyfinSequentialLauncher(
|
||||
context: ctx,
|
||||
clientForTesting: fakeClient,
|
||||
playbackStateForTesting: playback,
|
||||
navigateForTesting: (_) async {},
|
||||
);
|
||||
|
||||
final folder = MediaItem(
|
||||
id: 'folder-shuffle',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.unknown,
|
||||
serverId: 'srv-jf',
|
||||
);
|
||||
|
||||
final result = await launcher.launchFromFolder(folder: folder, shuffle: true, showLoadingIndicator: false);
|
||||
|
||||
expect(result, isA<PlayQueueSuccess>());
|
||||
final shuffledIds = playback.loadedItems.map((m) => m.id).toList();
|
||||
expect(shuffledIds.toSet(), originalIds.toSet());
|
||||
expect(shuffledIds.length, originalIds.length);
|
||||
expect(shuffledIds, isNot(equals(originalIds)));
|
||||
expect(playback.isShuffleActive, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('music-only folder returns PlayQueueEmpty', (tester) async {
|
||||
final ctx = await pumpContext(tester);
|
||||
final fakeClient = _RecordingJellyfinClient(playableFolderDescendantsResponse: [_track('a'), _track('b')]);
|
||||
final playback = PlaybackStateProvider();
|
||||
var didNavigate = false;
|
||||
|
||||
final launcher = JellyfinSequentialLauncher(
|
||||
context: ctx,
|
||||
clientForTesting: fakeClient,
|
||||
playbackStateForTesting: playback,
|
||||
navigateForTesting: (_) async {
|
||||
didNavigate = true;
|
||||
},
|
||||
);
|
||||
|
||||
final folder = MediaItem(
|
||||
id: 'music-folder',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.unknown,
|
||||
serverId: 'srv-jf',
|
||||
);
|
||||
|
||||
final result = await launcher.launchFromFolder(folder: folder, shuffle: false, showLoadingIndicator: false);
|
||||
|
||||
expect(result, isA<PlayQueueEmpty>());
|
||||
expect(playback.isQueueActive, isFalse);
|
||||
expect(didNavigate, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('launchShuffledShow rejects non-show/season kinds', (tester) async {
|
||||
final ctx = await pumpContext(tester);
|
||||
final launcher = JellyfinSequentialLauncher(context: ctx);
|
||||
|
||||
Reference in New Issue
Block a user