From 1958f8ca7e5ccf04d395193d8ffd0a1b43379cbf Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 26 May 2026 01:39:39 +0200 Subject: [PATCH] feat(jellyfin): add folder browsing and playback --- lib/media/media_kind.dart | 2 +- lib/media/server_capabilities.dart | 7 +- lib/screens/libraries/folder_tree_item.dart | 41 +++-- lib/screens/libraries/folder_tree_view.dart | 148 +++++++++++++----- .../libraries/tabs/library_browse_tab.dart | 7 +- .../jellyfin_client/parts/browse.dart | 112 ++++++++++++- .../jellyfin_sequential_launcher.dart | 71 ++++++++- .../media_list_playback_launcher.dart | 8 +- lib/services/play_queue_launcher.dart | 6 +- test/services/jellyfin_client_urls_test.dart | 100 ++++++++++++ test/services/jellyfin_mappers_test.dart | 18 +++ .../jellyfin_sequential_launcher_test.dart | 117 ++++++++++++++ test/widgets/folder_tree_item_test.dart | 63 ++++++++ 13 files changed, 625 insertions(+), 75 deletions(-) create mode 100644 test/widgets/folder_tree_item_test.dart diff --git a/lib/media/media_kind.dart b/lib/media/media_kind.dart index 483ad8bd..33221d71 100644 --- a/lib/media/media_kind.dart +++ b/lib/media/media_kind.dart @@ -55,7 +55,7 @@ enum MediaKind { 'track' || 'audio' => MediaKind.track, 'collection' || 'boxset' => MediaKind.collection, 'playlist' => MediaKind.playlist, - 'clip' || 'trailer' => MediaKind.clip, + 'clip' || 'trailer' || 'video' || 'musicvideo' => MediaKind.clip, 'photo' => MediaKind.photo, _ => MediaKind.unknown, }; diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 5dfcbdf9..4a83ba8b 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -102,9 +102,9 @@ class ServerCapabilities { /// gates whether the player attempts the load at all. final bool scrubThumbnails; - /// Library section exposes a folder hierarchy (Plex - /// `/library/sections/{id}/folders`). Jellyfin has no equivalent endpoint, - /// so the "Folders" grouping option is hidden when this is false. + /// Library section exposes a folder hierarchy. Plex uses + /// `/library/sections/{id}/folders`; Jellyfin uses direct-child + /// `/Items?ParentId=...&Recursive=false` queries. final bool folderGrouping; const ServerCapabilities({ @@ -179,6 +179,7 @@ class ServerCapabilities { richMetadataEdit: false, alphaBar: AlphaBarMode.nameStartsWithFilter, scrubThumbnails: true, + folderGrouping: true, ); ServerCapabilities copyWith({ diff --git a/lib/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index 45ba12ab..ad08626a 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -65,6 +65,18 @@ class FolderTreeItem extends StatelessWidget { }; } + String _rowTitle() { + final title = item.title?.trim(); + if (title != null && title.isNotEmpty) return title; + return item.displayTitle; + } + + String? _dedupeSubtitle(String? subtitle) { + final value = subtitle?.trim(); + if (value == null || value.isEmpty || value == _rowTitle()) return null; + return value; + } + void _handleTap() { if (isFolder) { onExpand?.call(); @@ -79,15 +91,12 @@ class FolderTreeItem extends StatelessWidget { if (item.parentIndex != null && item.index != null) { parts.add('S${item.parentIndex} E${item.index}'); } - if (item.title != null && item.title!.isNotEmpty) { - parts.add(item.title!); - } - return parts.isNotEmpty ? parts.join(' · ') : null; + return parts.isNotEmpty ? parts.join(' · ') : _dedupeSubtitle(item.displaySubtitle); } if (item.isSeason) { - return item.displaySubtitle; + return _dedupeSubtitle(item.displaySubtitle); } - return item.displaySubtitle; + return _dedupeSubtitle(item.displaySubtitle); } String _buildMetadataLine() { @@ -126,7 +135,7 @@ class FolderTreeItem extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - item.displayTitle, + _rowTitle(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -181,7 +190,7 @@ class FolderTreeItem extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - item.displayTitle, + _rowTitle(), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, height: 1.2), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -336,6 +345,8 @@ class FolderTreeItem extends StatelessWidget { @override Widget build(BuildContext context) { + final playAll = onPlayAll; + final shuffle = onShuffle; final rowContent = isFolder ? _buildFolderRow(context) : SettingsBuilder( @@ -362,13 +373,13 @@ class FolderTreeItem extends StatelessWidget { ), ), - // Play/Shuffle buttons for folders - if (isFolder) ...[ + // Play/Shuffle buttons for folders when the backend supports them. + if (isFolder && playAll != null) ...[ FocusableButton( useBackgroundFocus: true, - onPressed: onPlayAll, + onPressed: playAll, child: IconButton( - onPressed: onPlayAll, + onPressed: playAll, icon: AppIcon( Symbols.play_arrow_rounded, fill: 1, @@ -382,11 +393,13 @@ class FolderTreeItem extends StatelessWidget { visualDensity: VisualDensity.compact, ), ), + ], + if (isFolder && shuffle != null) ...[ FocusableButton( useBackgroundFocus: true, - onPressed: onShuffle, + onPressed: shuffle, child: IconButton( - onPressed: onShuffle, + onPressed: shuffle, icon: AppIcon( Symbols.shuffle_rounded, fill: 1, diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index d3c336e6..e2b6f849 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../media/media_backend.dart'; import '../../media/media_item.dart'; import '../../media/media_kind.dart'; +import '../../media/media_server_client.dart'; +import '../../services/jellyfin_client.dart'; +import '../../services/jellyfin_sequential_launcher.dart'; import '../../services/play_queue_launcher.dart'; +import '../../services/plex_client.dart'; import '../../utils/app_logger.dart'; import '../../utils/error_message_utils.dart'; import '../../utils/media_navigation_helper.dart'; @@ -17,6 +22,7 @@ import 'state_messages.dart'; class FolderTreeView extends StatefulWidget { final String libraryKey; final String? serverId; // Server this library belongs to + final MediaKind? libraryKind; final void Function(String)? onRefresh; final FocusNode? firstItemFocusNode; final VoidCallback? onNavigateUp; @@ -25,6 +31,7 @@ class FolderTreeView extends StatefulWidget { super.key, required this.libraryKey, this.serverId, + this.libraryKind, this.onRefresh, this.firstItemFocusNode, this.onNavigateUp, @@ -39,10 +46,9 @@ class FolderTreeViewState extends State { /// Reload the root folders. Exposed for parent-driven pull-to-refresh. Future refresh() => _loadRootFolders(); - /// Folders/items returned by the Plex `/library/sections/{id}/folder` - /// endpoint, mapped to neutral [MediaItem]s. The Plex `key` (folder URL) - /// survives in [MediaItem.raw] under the `'key'` slot — see - /// [_folderKey]. + /// Folders/items returned by the backend's folder API and mapped to neutral + /// [MediaItem]s. Plex folder URLs survive in [MediaItem.raw]['key']; + /// Jellyfin folders use the item id as their recursive parent id. List _rootFolders = []; final Map> _childrenCache = {}; final Set _expandedFolders = {}; @@ -51,11 +57,18 @@ class FolderTreeViewState extends State { String? _errorMessage; /// Resolve the Plex folder key from a [MediaItem]'s `raw` map. The key is - /// a relative URL (e.g. `/library/sections/1/folder?parent=...`) used as - /// the cache key and to recursively fetch children from - /// [PlexClient.getFolderChildren]. + /// a relative URL (e.g. `/library/sections/1/folder?parent=...`) used to + /// recursively fetch children from [PlexClient.fetchFolderChildren]. String? _folderKey(MediaItem item) => item.raw?['key'] as String?; + String? _itemType(MediaItem item) => (item.raw?['Type'] as String? ?? item.raw?['type'] as String?)?.toLowerCase(); + + String? _folderIdentity(MediaItem item) { + if (item.backend == MediaBackend.plex) return _folderKey(item); + if (_isFolder(item)) return item.id; + return null; + } + @override void initState() { super.initState(); @@ -69,11 +82,8 @@ class FolderTreeViewState extends State { }); try { - final client = context.getPlexClientForServer(widget.serverId!); - - // PlexClient.fetchLibraryFolders returns neutral [MediaItem]s; folders - // come back already tagged with the client's serverId/serverName. - final folders = await client.fetchLibraryFolders(widget.libraryKey); + final client = context.getMediaClientForServer(widget.serverId!); + final folders = await _fetchRootFolders(client); if (!mounted) return; @@ -94,40 +104,34 @@ class FolderTreeViewState extends State { } Future _loadFolderChildren(MediaItem folder) async { - final folderKey = _folderKey(folder); - if (folderKey == null) return; + final folderIdentity = _folderIdentity(folder); + if (folderIdentity == null) return; // Already loading this folder - if (_loadingFolders.contains(folderKey)) return; + if (_loadingFolders.contains(folderIdentity)) return; // Already loaded and cached - if (_childrenCache.containsKey(folderKey)) { + if (_childrenCache.containsKey(folderIdentity)) { setState(() { - _expandedFolders.add(folderKey); + _expandedFolders.add(folderIdentity); }); return; } setState(() { - _loadingFolders.add(folderKey); + _loadingFolders.add(folderIdentity); }); try { - final client = context.getPlexClientForServer(widget.serverId!); - - // Items are automatically tagged with server info by PlexClient. - final children = await client.fetchFolderChildren( - folderKey, - libraryId: folder.libraryId, - libraryTitle: folder.libraryTitle, - ); + final client = context.getMediaClientForServer(widget.serverId!); + final children = await _fetchFolderChildren(client, folder); if (!mounted) return; setState(() { - _childrenCache[folderKey] = children; - _expandedFolders.add(folderKey); - _loadingFolders.remove(folderKey); + _childrenCache[folderIdentity] = children; + _expandedFolders.add(folderIdentity); + _loadingFolders.remove(folderIdentity); }); appLogger.d('Loaded ${children.length} children for folder: ${folder.title}'); @@ -136,7 +140,7 @@ class FolderTreeViewState extends State { final message = mapUnexpectedErrorToMessage(e, context: t.libraries.folders); setState(() { - _loadingFolders.remove(folderKey); + _loadingFolders.remove(folderIdentity); }); if (mounted) { @@ -146,11 +150,11 @@ class FolderTreeViewState extends State { } void _toggleFolder(MediaItem folder) { - final folderKey = _folderKey(folder); - if (folderKey == null) return; - if (_expandedFolders.contains(folderKey)) { + final folderIdentity = _folderIdentity(folder); + if (folderIdentity == null) return; + if (_expandedFolders.contains(folderIdentity)) { setState(() { - _expandedFolders.remove(folderKey); + _expandedFolders.remove(folderIdentity); }); } else { _loadFolderChildren(folder); @@ -158,10 +162,26 @@ class FolderTreeViewState extends State { } Future _handleItemTap(MediaItem item) async { - await navigateToMediaItem(context, item, onRefresh: widget.onRefresh); + final result = await navigateToMediaItem(context, item, onRefresh: widget.onRefresh); + if (!context.mounted) return; + switch (result) { + case MediaNavigationResult.unsupported: + showAppSnackBar(context, t.messages.musicNotSupported); + case MediaNavigationResult.listRefreshNeeded: + widget.onRefresh?.call(item.id); + case MediaNavigationResult.navigated: + case MediaNavigationResult.librarySelected: + break; + } } Future _handleFolderPlay(MediaItem folder) async { + if (folder.backend == MediaBackend.jellyfin) { + final launcher = JellyfinSequentialLauncher(context: context); + await launcher.launchFromFolder(folder: folder, shuffle: false); + return; + } + final folderKey = _folderKey(folder); if (folderKey == null) return; final client = context.getPlexClientForServer(widget.serverId!); @@ -175,6 +195,12 @@ class FolderTreeViewState extends State { } Future _handleFolderShuffle(MediaItem folder) async { + if (folder.backend == MediaBackend.jellyfin) { + final launcher = JellyfinSequentialLauncher(context: context); + await launcher.launchFromFolder(folder: folder, shuffle: true); + return; + } + final folderKey = _folderKey(folder); if (folderKey == null) return; final client = context.getPlexClientForServer(widget.serverId!); @@ -188,10 +214,47 @@ class FolderTreeViewState extends State { } bool _isFolder(MediaItem item) { - // Folders typically have no media kind (Plex returns `type: 'folder'`, - // mapped to [MediaKind.unknown]) or expose `/folder` in their key. + if (item.backend == MediaBackend.jellyfin) { + return _isJellyfinFilesystemFolder(item) || _isJellyfinMediaContainer(item); + } + + // Plex folders typically have no media kind (mapped to [MediaKind.unknown]) + // or expose `/folder` in their key. final folderKey = _folderKey(item); - return folderKey?.contains('/folder') == true || item.kind == MediaKind.unknown; + final type = _itemType(item); + return folderKey?.contains('/folder') == true || type == 'folder' || item.kind == MediaKind.unknown; + } + + bool _isJellyfinFilesystemFolder(MediaItem item) { + final type = _itemType(item); + return type == 'folder' || type == 'collectionfolder' || (type == null && item.raw?['IsFolder'] == true); + } + + bool _isJellyfinMediaContainer(MediaItem item) => item.kind == MediaKind.show || item.kind == MediaKind.season; + + bool _canPlayFolder(MediaItem item) { + if (item.backend == MediaBackend.plex) return true; + if (item.backend == MediaBackend.jellyfin) return widget.libraryKind?.isMusic != true; + return false; + } + + Future> _fetchRootFolders(MediaServerClient client) { + if (client is PlexClient) return client.fetchLibraryFolders(widget.libraryKey); + if (client is JellyfinClient) return client.fetchLibraryFolders(widget.libraryKey); + throw UnsupportedError('Folder browsing is not supported for ${client.backend.id}'); + } + + Future> _fetchFolderChildren(MediaServerClient client, MediaItem folder) { + if (client is PlexClient) { + final folderKey = _folderKey(folder); + if (folderKey == null) return Future.value(const []); + return client.fetchFolderChildren(folderKey, libraryId: folder.libraryId, libraryTitle: folder.libraryTitle); + } + if (client is JellyfinClient) { + if (_isJellyfinMediaContainer(folder)) return client.fetchChildren(folder.id); + return client.fetchFolderChildren(folder.id); + } + throw UnsupportedError('Folder browsing is not supported for ${client.backend.id}'); } /// Flatten the visible tree into a list of (item, depth, path) tuples so @@ -207,7 +270,7 @@ class FolderTreeViewState extends State { final itemPath = parentPath.isEmpty ? '$i' : '$parentPath-$i'; out.add((item: item, depth: depth, path: itemPath)); - final folderKey = _folderKey(item); + final folderKey = _folderIdentity(item); if (_isFolder(item) && folderKey != null && _expandedFolders.contains(folderKey) && @@ -253,10 +316,11 @@ class FolderTreeViewState extends State { final entry = flattened[index]; final item = entry.item; final isFolder = _isFolder(item); - final folderKey = _folderKey(item); + final folderKey = _folderIdentity(item); final isExpanded = folderKey != null && _expandedFolders.contains(folderKey); final isLoading = folderKey != null && _loadingFolders.contains(folderKey); final isFirstRootItem = index == 0; + final canPlayFolder = isFolder && _canPlayFolder(item); return FolderTreeItem( key: ValueKey(entry.path), @@ -268,8 +332,8 @@ class FolderTreeViewState extends State { serverId: widget.serverId, onExpand: isFolder ? () => _toggleFolder(item) : null, onTap: !isFolder ? () => _handleItemTap(item) : null, - onPlayAll: isFolder ? () => _handleFolderPlay(item) : null, - onShuffle: isFolder ? () => _handleFolderShuffle(item) : null, + onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null, + onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null, focusNode: isFirstRootItem ? widget.firstItemFocusNode : null, onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null, ); diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 172f5719..3a2ca291 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -689,10 +689,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _getGroupingOptions() { final type = widget.library.kind.id.toLowerCase(); - // Folder browsing relies on a section folder API - // (Plex `/library/sections/{id}/folders`); gated by capability so any - // future backend that exposes the same can opt in without touching - // this method. + // Folder browsing is gated by backend capability: Plex uses its section + // folder API, while Jellyfin uses direct non-recursive Items queries. final canFolder = context.tryGetMediaClientForServer(widget.library.serverId)?.capabilities.folderGrouping ?? false; if (type == 'show') { return ['shows', 'seasons', 'episodes', if (canFolder) 'folders']; @@ -1491,6 +1489,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState item) { + final type = (item['Type'] as String?)?.toLowerCase(); + return type == 'folder' || type == 'collectionfolder' || (type == null && item['IsFolder'] == true); +} + +String _jellyfinFolderSortName(Map item) { + final raw = item['SortName'] as String? ?? item['Name'] as String? ?? ''; + return raw.toLowerCase(); +} int _fallbackPageTotal({required int offset, required int itemCount, int? requestedSize}) { if (requestedSize == null || requestedSize <= 0 || itemCount < requestedSize) { @@ -506,6 +518,68 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return _mapItems(allRaw); } + /// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query + /// direct children of the library/folder with `Recursive=false`. This is + /// distinct from [fetchLibraryContent], which intentionally recurses through + /// a library to show metadata groupings like albums, artists, shows, etc. + Future> fetchLibraryFolders(String libraryId) => _fetchFolderChildren(libraryId); + + /// Contents of a Jellyfin folder. Kept separate from [fetchChildren] so the + /// folder tree can use direct-child semantics even for music libraries. + Future> fetchFolderChildren(String folderId) => _fetchFolderChildren(folderId); + + Future> _fetchFolderChildren(String parentId) async { + final cacheKey = '/Items?ParentId=$parentId&Recursive=false&userId=${connection.userId}'; + if (isOfflineMode) { + final cached = await cache.get(cacheServerId, cacheKey); + return cached == null ? const [] : _mapItems(_itemsArray(cached)); + } + + final allRaw = >[]; + var startIndex = 0; + int? totalRecordCount; + while (totalRecordCount == null || startIndex < totalRecordCount) { + final response = await _http.get( + '/Items', + queryParameters: { + 'userId': connection.userId, + 'ParentId': parentId, + 'Recursive': 'false', + 'StartIndex': '$startIndex', + 'Limit': '$_childrenPageSize', + 'EnableTotalRecordCount': 'true', + 'SortBy': 'IsFolder,SortName', + 'SortOrder': 'Ascending', + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }, + ); + throwIfHttpError(response); + final data = response.data; + final page = _itemsArray(data); + allRaw.addAll(page); + if (data is Map) { + final rawTotal = data['TotalRecordCount']; + if (rawTotal is int) totalRecordCount = rawTotal; + } + if (page.isEmpty || page.length < _childrenPageSize) break; + startIndex += page.length; + } + + allRaw.sort((a, b) { + final folderRank = (_isJellyfinFolderDto(a) ? 0 : 1).compareTo(_isJellyfinFolderDto(b) ? 0 : 1); + if (folderRank != 0) return folderRank; + return _jellyfinFolderSortName(a).compareTo(_jellyfinFolderSortName(b)); + }); + + try { + await cache.put(cacheServerId, cacheKey, {'Items': allRaw, 'TotalRecordCount': allRaw.length}); + } catch (e, st) { + appLogger.w('JellyfinClient.fetchFolderChildren cache write failed', error: e, stackTrace: st); + } + 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 @@ -514,11 +588,27 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { /// since those preserve the container shape (Series rows, PlaylistItemId). /// @override - Future> fetchPlayableDescendants(String parentId) async { + Future> fetchPlayableDescendants(String parentId) { + return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableDescendantTypes); + } + + /// Playable video descendants for a folder browse row. This includes + /// Jellyfin's generic `Video` / `MusicVideo` kinds for home-video libraries, + /// but deliberately excludes `Audio` so folder playback never starts music. + Future> fetchPlayableFolderDescendants(String parentId) { + return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableFolderDescendantTypes); + } + + Future> _fetchAllPlayableDescendants(String parentId, {required String includeItemTypes}) async { final all = []; var start = 0; while (true) { - final page = await fetchPlayableDescendantsPage(parentId, start: start, size: _pagedListPageSize); + final page = await _fetchPlayableDescendantsPage( + parentId, + start: start, + size: _pagedListPageSize, + includeItemTypes: includeItemTypes, + ); if (page.items.isEmpty) break; all.addAll(page.items); start += page.items.length; @@ -533,6 +623,22 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { int? start, int? size, AbortController? abort, + }) { + return _fetchPlayableDescendantsPage( + parentId, + start: start, + size: size, + abort: abort, + includeItemTypes: _playableDescendantTypes, + ); + } + + Future> _fetchPlayableDescendantsPage( + String parentId, { + int? start, + int? size, + AbortController? abort, + required String includeItemTypes, }) async { final offset = start ?? 0; final pageSize = size ?? _pagedListPageSize; @@ -542,7 +648,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'userId': connection.userId, 'ParentId': parentId, 'Recursive': 'true', - 'IncludeItemTypes': 'Movie,Episode', + 'IncludeItemTypes': includeItemTypes, 'StartIndex': offset.toString(), 'Limit': pageSize.toString(), 'Fields': _browseFields, diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart index 23bb5e39..02a18f1c 100644 --- a/lib/services/jellyfin_sequential_launcher.dart +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -11,10 +11,11 @@ import '../media/play_queue.dart'; import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; import '../utils/snackbar_helper.dart'; +import 'jellyfin_client.dart'; import 'media_list_playback_launcher.dart'; import 'playlist_items_loader.dart'; -/// Backend-neutral launcher for Jellyfin collections and playlists. +/// Backend-neutral launcher for Jellyfin collections, playlists, and folders. /// /// Jellyfin has no server-side queue resource — the client fetches /// children (collection) or playlist items, applies shuffle locally, @@ -123,6 +124,74 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { ); } + /// Launch playback from a Jellyfin folder row. Jellyfin has no server-side + /// queue resource, so folders use the same local queue path as collections. + /// The client query is video-only; music-only folders return [PlayQueueEmpty]. + Future launchFromFolder({ + required MediaItem folder, + required bool shuffle, + bool showLoadingIndicator = true, + }) async { + final serverId = folder.serverId; + if (serverId == null) { + return PlayQueueError(Exception('Item is missing serverId')); + } + + return executeWithLoading( + context: context, + showLoading: showLoadingIndicator, + actionLabel: shuffle ? t.common.shuffle : t.common.play, + execute: (dismissLoading) async { + final client = clientForTesting ?? _resolveClient(serverId); + if (client == null) { + await dismissLoading(); + if (context.mounted) { + showErrorSnackBar(context, t.errors.noClientAvailable); + } + return PlayQueueError(Exception('No client for server $serverId')); + } + + final fetched = client is JellyfinClient + ? await client.fetchPlayableFolderDescendants(folder.id) + : await client.fetchPlayableDescendants(folder.id); + var items = fetched.where((item) => item.kind.isVideo).map((item) { + return item.copyWith( + serverId: item.serverId ?? serverId, + serverName: item.serverName ?? folder.serverName, + libraryId: item.libraryId ?? folder.libraryId, + libraryTitle: item.libraryTitle ?? folder.libraryTitle, + ); + }).toList(); + + if (items.isEmpty) return const PlayQueueEmpty(); + + if (shuffle) { + items = List.of(items)..shuffle(Random()); + } + + await dismissLoading(); + if (!context.mounted && navigateForTesting == null) { + return const PlayQueueError('Context not mounted'); + } + + final playbackState = playbackStateForTesting ?? context.read(); + return launchLocalQueuePlayback( + context: context, + playbackState: playbackState, + queue: LocalPlayQueue( + id: 'jellyfin:folder:${folder.id}', + items: items, + currentIndex: 0, + shuffled: shuffle, + backendId: client.backend.id, + ), + contextKey: folder.id, + navigateForTesting: navigateForTesting, + ); + }, + ); + } + @override Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async { final kind = metadata.kind; diff --git a/lib/services/media_list_playback_launcher.dart b/lib/services/media_list_playback_launcher.dart index 51b9449b..958b7692 100644 --- a/lib/services/media_list_playback_launcher.dart +++ b/lib/services/media_list_playback_launcher.dart @@ -38,10 +38,10 @@ class PlayQueueError extends PlayQueueResult { /// Backend-neutral playback launcher for collections and playlists. /// /// Plex uses server-side `/playQueues` (one round trip, server tracks -/// queue state). Jellyfin has no equivalent — the client builds an -/// in-memory queue from `fetchChildren` (collection) or -/// `fetchPlaylistItems` (playlist). [MediaListPlaybackLauncher.forItem] -/// picks the implementation by inspecting the item's backend. +/// queue state). Jellyfin has no equivalent — the concrete Jellyfin launcher +/// builds an in-memory queue from playable descendants or playlist items. +/// [MediaListPlaybackLauncher.forItem] picks the implementation by inspecting +/// the item's backend. abstract class MediaListPlaybackLauncher { /// Launch playback from a collection (a [MediaItem] with /// `kind == MediaKind.collection`) or a [MediaPlaylist]. diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 2fa20780..d77c093f 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -26,9 +26,9 @@ export 'media_list_playback_launcher.dart' show PlayQueueResult, PlayQueueSucces /// 4. Handling errors with appropriate feedback /// /// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for -/// the backend-neutral entry point. Plex-only flows -/// ([launchFromPlaylistItem], [launchShuffledShow], [launchFromFolder]) live -/// directly on this class because they have no Jellyfin equivalent. +/// the backend-neutral entry point. Plex-only flows such as +/// [launchFromPlaylistItem] live directly on this class because they have no +/// Jellyfin equivalent. class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { final BuildContext context; final PlexClient client; diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 28306fa2..4b9f64fb 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -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 = []; + 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 = []; 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 = []; final mock = MockClient((req) async { diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index 7fe2b527..405e76cb 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -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', diff --git a/test/services/jellyfin_sequential_launcher_test.dart b/test/services/jellyfin_sequential_launcher_test.dart index 983ec4dc..f8550b80 100644 --- a/test/services/jellyfin_sequential_launcher_test.dart +++ b/test/services/jellyfin_sequential_launcher_test.dart @@ -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 playableDescendantsResponse; + final List playableFolderDescendantsResponse; final List seriesEpisodesResponse; final List playlistItemsResponse; final List fetchPlayableDescendantsCalls = []; + final List fetchPlayableFolderDescendantsCalls = []; final List 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> fetchPlayableFolderDescendants(String parentId) async { + fetchPlayableFolderDescendantsCalls.add(parentId); + return playableFolderDescendantsResponse; + } + @override Future?> 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 = []; + + 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()); + 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()); + 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()); + 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); diff --git a/test/widgets/folder_tree_item_test.dart b/test/widgets/folder_tree_item_test.dart new file mode 100644 index 00000000..e38822b3 --- /dev/null +++ b/test/widgets/folder_tree_item_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/screens/libraries/folder_tree_item.dart'; + +void main() { + testWidgets('folder rows use the item title for seasons', (tester) async { + final item = MediaItem( + id: 'season-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Season 1', + grandparentTitle: 'TV Show', + raw: const {'Type': 'Season', 'IsFolder': true}, + ); + + await tester.pumpWidget( + MaterialApp( + home: Material(child: FolderTreeItem(item: item, depth: 1, isFolder: true)), + ), + ); + + expect(find.text('Season 1'), findsOneWidget); + expect(find.text('TV Show'), findsNothing); + expect(find.byType(IconButton), findsNothing); + }); + + testWidgets('folder rows show play and shuffle buttons when callbacks are supplied', (tester) async { + final item = MediaItem( + id: 'folder-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.unknown, + title: 'Videos', + raw: const {'Type': 'Folder', 'IsFolder': true}, + ); + + var played = false; + var shuffled = false; + await tester.pumpWidget( + MaterialApp( + home: Material( + child: FolderTreeItem( + item: item, + depth: 0, + isFolder: true, + onPlayAll: () => played = true, + onShuffle: () => shuffled = true, + ), + ), + ), + ); + + expect(find.byType(IconButton), findsNWidgets(2)); + + await tester.tap(find.byType(IconButton).first); + await tester.tap(find.byType(IconButton).last); + + expect(played, isTrue); + expect(shuffled, isTrue); + }); +}