feat(jellyfin): add folder browsing and playback

This commit is contained in:
edde746
2026-05-26 01:39:39 +02:00
parent 8e53232961
commit 1958f8ca7e
13 changed files with 625 additions and 75 deletions
+109 -3
View File
@@ -42,6 +42,18 @@ const _episodeQueuePageSize = 200;
const _childrenPageSize = 500;
const _pagedListPageSize = 200;
const _playableDescendantTypes = 'Movie,Episode';
const _playableFolderDescendantTypes = 'Movie,Episode,Video,MusicVideo';
bool _isJellyfinFolderDto(Map<String, dynamic> item) {
final type = (item['Type'] as String?)?.toLowerCase();
return type == 'folder' || type == 'collectionfolder' || (type == null && item['IsFolder'] == true);
}
String _jellyfinFolderSortName(Map<String, dynamic> 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<List<MediaItem>> 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<List<MediaItem>> fetchFolderChildren(String folderId) => _fetchFolderChildren(folderId);
Future<List<MediaItem>> _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 = <Map<String, dynamic>>[];
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<String, dynamic>) {
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<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
Future<List<MediaItem>> 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<List<MediaItem>> fetchPlayableFolderDescendants(String parentId) {
return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableFolderDescendantTypes);
}
Future<List<MediaItem>> _fetchAllPlayableDescendants(String parentId, {required String includeItemTypes}) async {
final all = <MediaItem>[];
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<LibraryPage<MediaItem>> _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,