From 0f1bf5e0118133b3599227c146a8c7b391f54042 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:13:57 +0200 Subject: [PATCH] perf(jellyfin): skip per-folder user data in folder tree listings --- .gitignore | 2 +- .../jellyfin_client/parts/browse.dart | 96 +++++++++--- scripts/gen_framesync_sample.sh | 143 ------------------ test/services/jellyfin_client_urls_test.dart | 77 ++++++---- 4 files changed, 121 insertions(+), 197 deletions(-) delete mode 100755 scripts/gen_framesync_sample.sh diff --git a/.gitignore b/.gitignore index 931d27de..8fc4708e 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,4 @@ duplication-report/ **/fastlane/report.xml maestro/**/*.png -server/deploy.shscripts/framesync/ +server/deploy.sh \ No newline at end of file diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 75d9b6a5..a7f1208e 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -33,15 +33,22 @@ const _browseFields = 'RecursiveItemCount,ChildCount,UserData,PremiereDate,Origi /// queries because it is the heaviest item field Jellyfin returns. const _episodeRowFields = '$_browseFields,MediaSources'; -/// Folder-tree field set. The tree renders title/thumb/watch state plus -/// default dto fields (year, runtime, ratings); it deliberately skips -/// `RecursiveItemCount`/`ChildCount` — per-item COUNT queries the server -/// runs for every folder/series row, which made large folder listings very -/// slow — and `Overview`, which the tree never shows. Jellyfin web's folder -/// view requests none of them either. The unwatched badge survives via -/// `UserData.UnplayedItemCount` ([MediaItem.unwatchedCount] fallback). +/// Folder-tree field set for MEDIA children. The tree renders +/// title/thumb/watch state plus default dto fields (year, runtime, ratings); +/// it deliberately skips `RecursiveItemCount`/`ChildCount` — per-item COUNT +/// queries the server runs for every folder/series row, which made large +/// folder listings very slow — and `Overview`, which the tree never shows. +/// Jellyfin web's folder view requests none of them either. The unwatched +/// badge survives via `UserData.UnplayedItemCount` +/// ([MediaItem.unwatchedCount] fallback). const _folderBrowseFields = 'UserData,PremiereDate,OriginalTitle,SortName'; +/// Folder-tree field set for FILESYSTEM FOLDER children, which render only +/// their name. Queried with `EnableUserData=false`: user data on a folder dto +/// makes the server compute a recursive unplayed count per folder, by far the +/// dominant cost of folder browsing (see [_fetchFolderChildren]). +const _folderRowFields = 'SortName'; + /// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows /// only need title, thumbnail (`ImageTags['Primary']`), season/episode /// index, and watched state. Title + indices come back without any @@ -631,18 +638,16 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { Future> fetchFolderChildren(String folderId, {void Function(List itemsSoFar)? onPage}) => _fetchFolderChildren(folderId, onPage: onPage); - Future> _fetchFolderChildren( - String parentId, { - void Function(List itemsSoFar)? onPage, + /// Page through `/Items?ParentId=...&Recursive=false` with the given type + /// filter. [onRawPage] receives the accumulated rows after each intermediate + /// page (never for single-page listings or the final page). + Future>> _pageFolderQuery( + String parentId, + Map typeParams, + String fields, { + void Function(List> rowsSoFar)? onRawPage, }) async { - final cacheKey = '/Items?ParentId=$parentId&Recursive=false&userId=${connection.userId}'; - if (isOfflineMode) { - final cached = await cache.get(ServerId(cacheServerId), cacheKey); - return cached == null ? const [] : _mapItems(_itemsArray(cached)); - } - - final allRaw = >[]; - final mappedSoFar = onPage == null ? null : []; + final out = >[]; var startIndex = 0; int? totalRecordCount; while (totalRecordCount == null || startIndex < totalRecordCount) { @@ -655,27 +660,70 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'StartIndex': '$startIndex', 'Limit': '$_childrenPageSize', 'EnableTotalRecordCount': 'true', - 'SortBy': 'IsFolder,SortName', + 'SortBy': 'SortName', 'SortOrder': 'Ascending', - 'Fields': _folderBrowseFields, + 'Fields': fields, + ...typeParams, ...jellyfinImageQueryParameters, }, ); throwIfHttpError(response); final data = response.data; final page = _itemsArray(data); - allRaw.addAll(page); - mappedSoFar?.addAll(_mapItems(page)); + out.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; - if (mappedSoFar != null && (totalRecordCount == null || startIndex < totalRecordCount)) { - onPage!(List.unmodifiable(mappedSoFar)); + if (onRawPage != null && (totalRecordCount == null || startIndex < totalRecordCount)) { + onRawPage(out); } } + return out; + } + + Future> _fetchFolderChildren( + String parentId, { + void Function(List itemsSoFar)? onPage, + }) async { + final cacheKey = '/Items?ParentId=$parentId&Recursive=false&userId=${connection.userId}'; + if (isOfflineMode) { + final cached = await cache.get(ServerId(cacheServerId), cacheKey); + return cached == null ? const [] : _mapItems(_itemsArray(cached)); + } + + // Two parallel queries split by type: attaching UserData to a folder dto + // makes Jellyfin compute a recursive unplayed count PER FOLDER (measured + // ~100-200ms each on a real 10.11 server — the dominant cost of folder + // browsing), and the tree renders no watch state on plain folder rows. + // Media children keep UserData: leaves resolve it with a cheap lookup and + // series need it for the unwatched badge. Folders-then-media matches the + // folders-first ordering the final sort below produces. + List>? folderRows; + final foldersFuture = _pageFolderQuery(parentId, { + 'IncludeItemTypes': 'Folder,CollectionFolder', + 'EnableUserData': 'false', + }, _folderRowFields).then((rows) => folderRows = rows); + + final mediaFuture = _pageFolderQuery( + parentId, + {'ExcludeItemTypes': 'Folder,CollectionFolder'}, + _folderBrowseFields, + onRawPage: onPage == null + ? null + : (rowsSoFar) { + // Only emit once the (typically single, fast) folders query has + // landed so partial snapshots never reorder later. + final folders = folderRows; + if (folders == null) return; + onPage(List.unmodifiable(_mapItems([...folders, ...rowsSoFar]))); + }, + ); + + final results = await Future.wait([foldersFuture, mediaFuture]); + final allRaw = >[...results[0], ...results[1]]; allRaw.sort((a, b) { final folderRank = (_isJellyfinFolderDto(a) ? 0 : 1).compareTo(_isJellyfinFolderDto(b) ? 0 : 1); diff --git a/scripts/gen_framesync_sample.sh b/scripts/gen_framesync_sample.sh deleted file mode 100755 index 3e4f3249..00000000 --- a/scripts/gen_framesync_sample.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env bash -# Generates MKV test assets that prove (or disprove) frame-perfect ASS rendering: -# the video has a per-frame counter burned in (top) and the MKV carries an ASS -# track flipping the same counter (bottom) on exactly the same frame boundaries. -# Any captured frame showing video number N with subtitle number != N is a sync -# failure. -# -# Outputs (in scripts/framesync/): -# framesync-2397.mkv 23.976 fps, plain per-frame counter events -# framesync-60.mkv 60 fps variant -# framesync-stress.mkv 23.976 fps + heavy animated typesetting (blur/scale/ -# rotation re-rendered every frame) to stress libass -# -# The burn-in uses, in order of preference: ffmpeg drawtext, ffmpeg subtitles -# filter, or mpv's encoding mode (mpv always bundles libass) — all three -# evaluate the counter at each frame's exact PTS, so the burned reference is -# frame-exact by construction. -# -# Verification procedure (Android, ExoPlayer path): -# 1. Play the file with the ASS track selected (SDR content and the -# ASS-tunneling block mean the layers are screen-recordable). -# 2. adb shell screenrecord /sdcard/sync.mp4 (record ~30 s, pull it) -# 3. Step through frames (e.g. ffmpeg -i sync.mp4 frames/%05d.png, or mpv with -# '.' frame-step): top (video) and bottom (player-rendered ASS) counters -# must match on EVERY captured frame. Desktop mpv is the reference player. -# 4. Cross-check at the compositor: while playing, -# adb shell dumpsys SurfaceFlinger --latency -# lists per-layer (desired, actual) present times for the video SurfaceView -# and the ASS overlay layer — matched content must share vsync timestamps. -# 5. Cheap regression: the in-app stats overlay should show subLateSwaps ≈ 0, -# subOverflows == 0, subMinLeadMs ≥ 0 and a >95% subSpecHits ratio. -set -euo pipefail -cd "$(dirname "${BASH_SOURCE[0]}")" -mkdir -p framesync -cd framesync - -DURATION_S=${DURATION_S:-60} -# drawtext resolves the family via fontconfig; override with FONT if needed. -FONT=${FONT:-Sans} - -have_filter() { ffmpeg -hide_banner -filters 2>/dev/null | grep -q " $1 "; } - -# Emits an ASS file with one Dialogue event per frame. Frame i starts at -# i*den/num seconds; timestamps are floored to ASS centisecond precision, which -# is safe because the floor error (<10 ms) is smaller than any frame interval -# generated here (16.7 / 41.7 ms) — an event can never leak onto the previous -# frame. burn=1 emits the top-aligned white reference style (for burn-in), -# burn=0 the bottom-aligned yellow player style; heavy=1 adds per-frame -# animated blur/scale/rotation events. -gen_ass() { # $1=fps_num $2=fps_den $3=frames $4=burn(0/1) $5=heavy(0/1) $6=outfile - awk -v num="$1" -v den="$2" -v frames="$3" -v burn="$4" -v heavy="$5" ' - function ts(cs, h, m, s) { - h = int(cs / 360000); cs -= h * 360000 - m = int(cs / 6000); cs -= m * 6000 - s = int(cs / 100); cs -= s * 100 - return sprintf("%d:%02d:%02d.%02d", h, m, s, cs) - } - BEGIN { - print "[Script Info]" - print "ScriptType: v4.00+" - print "PlayResX: 1280" - print "PlayResY: 720" - print "" - print "[V4+ Styles]" - print "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding" - if (burn) - print "Style: Counter,Arial,150,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,5,0,8,10,10,30,1" - else - print "Style: Counter,Arial,150,&H0000FFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,5,0,2,10,10,30,1" - print "Style: Stress,Arial,80,&H40FF8800,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,5,10,10,10,1" - print "" - print "[Events]" - print "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text" - for (i = 0; i < frames; i++) { - start = int(i * den * 100 / num) - end = int((i + 1) * den * 100 / num) - if (end <= start) end = start + 1 - printf "Dialogue: 0,%s,%s,Counter,,0,0,0,,%d\n", ts(start), ts(end), i - if (heavy) { - # Re-emitted every frame with a frame-dependent rotation/blur so libass - # reports changed content and re-rasterizes large blurred glyphs — - # approximates typesetting storms (the measured 100-400 ms frames). - printf "Dialogue: 1,%s,%s,Stress,,0,0,0,,{\\an5\\pos(640,260)\\blur12\\fscx320\\fscy320\\frz%d}#\n", ts(start), ts(end), (i * 7) % 360 - printf "Dialogue: 1,%s,%s,Stress,,0,0,0,,{\\an5\\pos(640,500)\\blur18\\fscx500\\fscy220\\frz%d\\alpha&H80&}@\n", ts(start), ts(end), 359 - (i * 11) % 360 - } - } - } - ' /dev/null > "$6" -} - -# Produces the reference video: gray frames with the frame counter burned in at -# the top. Tries drawtext, then the subtitles filter, then mpv encoding. -gen_video() { # $1=rate $2=burn_ass $3=outfile - if have_filter drawtext; then - ffmpeg -y -v error -f lavfi -i "color=c=0x202020:s=1280x720:r=$1" \ - -vf "drawtext=font='${FONT}':text='%{n}':fontsize=150:fontcolor=white:borderw=5:bordercolor=black:x=(w-text_w)/2:y=30" \ - -t "$DURATION_S" -c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "$3" - elif have_filter subtitles; then - ffmpeg -y -v error -f lavfi -i "color=c=0x202020:s=1280x720:r=$1" \ - -vf "subtitles=$2" \ - -t "$DURATION_S" -c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "$3" - elif command -v mpv > /dev/null; then - ffmpeg -y -v error -f lavfi -i "color=c=0x202020:s=1280x720:r=$1" \ - -t "$DURATION_S" -c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p blank.mp4 - mpv blank.mp4 --sub-files="$2" --vf=sub --no-audio \ - --o="$3" --of=mp4 --ovc=libx264 --ovcopts=preset=veryfast,crf=18 \ - --msg-level=all=error - rm -f blank.mp4 - else - echo "error: need ffmpeg with drawtext or subtitles filter, or mpv (for the burn-in)" >&2 - exit 1 - fi -} - -# Muxes the burned video + silent audio + the player-rendered ASS track. -mux() { # $1=video $2=ass $3=outfile - ffmpeg -y -v error -i "$1" -f lavfi -i "anullsrc=r=48000:cl=stereo" -i "$2" \ - -map 0:v -map 1:a -map 2 -c:v copy -c:a aac -b:a 64k -c:s copy -shortest \ - -metadata:s:s:0 language=eng -disposition:s:0 default "$3" -} - -frames_2397=$(awk -v d="$DURATION_S" 'BEGIN { print int(d * 24000 / 1001) }') -frames_60=$(awk -v d="$DURATION_S" 'BEGIN { print d * 60 }') - -echo "Generating ${DURATION_S}s assets..." -gen_ass 24000 1001 "$frames_2397" 1 0 burn-2397.ass -gen_ass 60 1 "$frames_60" 1 0 burn-60.ass -gen_ass 24000 1001 "$frames_2397" 0 0 counter-2397.ass -gen_ass 60 1 "$frames_60" 0 0 counter-60.ass -gen_ass 24000 1001 "$frames_2397" 0 1 counter-stress.ass -gen_video "24000/1001" burn-2397.ass video-2397.mp4 -gen_video "60" burn-60.ass video-60.mp4 -mux video-2397.mp4 counter-2397.ass framesync-2397.mkv -mux video-60.mp4 counter-60.ass framesync-60.mkv -mux video-2397.mp4 counter-stress.ass framesync-stress.mkv -rm -f video-2397.mp4 video-60.mp4 burn-2397.ass burn-60.ass - -echo "Done:" -ls -la framesync-*.mkv -echo -echo "Reference check: open framesync-2397.mkv in mpv, frame-step with '.' —" -echo "top (video) and bottom (subtitle) counters must match on every frame." -echo "Then repeat in Plezy on-device and screenrecord (see header comments)." diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 96c8ad28..a60939f6 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -1639,22 +1639,22 @@ void main() { expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode'); }); - test('fetchLibraryFolders uses direct non-recursive Items query and orders folders first', () async { - Uri? captured; + test('fetchLibraryFolders splits folder/media queries and orders folders first', () async { + const allChildren = [ + {'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}, + ]; + final captured = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { - captured = req.url; + captured.add(req.url); + final foldersOnly = req.url.queryParameters['IncludeItemTypes'] == 'Folder,CollectionFolder'; + final items = allChildren.where((c) => (c['Type'] == 'Folder') == foldersOnly).toList(); 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, - }), + jsonEncode({'Items': items, 'TotalRecordCount': items.length}), 200, headers: {'content-type': 'application/json'}, ); @@ -1664,20 +1664,31 @@ void main() { 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'))); - // Folder listings use the slim field set: the per-item count fields are - // expensive server-side and Overview is never rendered in the tree. - expect(captured!.queryParameters['Fields'], isNot(contains('RecursiveItemCount'))); - expect(captured!.queryParameters['Fields'], isNot(contains('ChildCount'))); - expect(captured!.queryParameters['Fields'], isNot(contains('Overview'))); - expect(captured!.queryParameters['Fields'], contains('UserData')); + expect(captured, hasLength(2)); + final folderQuery = captured.firstWhere((u) => u.queryParameters.containsKey('IncludeItemTypes')); + final mediaQuery = captured.firstWhere((u) => u.queryParameters.containsKey('ExcludeItemTypes')); + for (final uri in [folderQuery, mediaQuery]) { + expect(uri.path, '/Items'); + expect(uri.queryParameters['ParentId'], 'lib-1'); + expect(uri.queryParameters['Recursive'], 'false'); + expect(uri.queryParameters['EnableTotalRecordCount'], 'true'); + expect(uri.queryParameters['SortBy'], 'SortName'); + expect(uri.queryParameters['SortOrder'], 'Ascending'); + // Slim field sets: per-item count fields are expensive server-side + // and Overview is never rendered in the tree. + expect(uri.queryParameters['Fields'], isNot(contains('MediaSources'))); + expect(uri.queryParameters['Fields'], isNot(contains('RecursiveItemCount'))); + expect(uri.queryParameters['Fields'], isNot(contains('ChildCount'))); + expect(uri.queryParameters['Fields'], isNot(contains('Overview'))); + } + // User data on folder dtos triggers a per-folder recursive unplayed + // count on the server; folder rows render no watch state, so skip it. + expect(folderQuery.queryParameters['IncludeItemTypes'], 'Folder,CollectionFolder'); + expect(folderQuery.queryParameters['EnableUserData'], 'false'); + expect(folderQuery.queryParameters['Fields'], isNot(contains('UserData'))); + // Media rows keep user data (watched state, series unwatched badge). + expect(mediaQuery.queryParameters['ExcludeItemTypes'], 'Folder,CollectionFolder'); + expect(mediaQuery.queryParameters['Fields'], contains('UserData')); 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); @@ -1685,12 +1696,20 @@ void main() { }); test('fetchFolderChildren pages direct folder contents', () async { - final starts = []; + final mediaStarts = []; final pages = >[]; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { - starts.add(req.url.queryParameters['StartIndex']); + if (req.url.queryParameters.containsKey('IncludeItemTypes')) { + // Folders query — this directory has none. + return http.Response( + jsonEncode({'Items': const [], 'TotalRecordCount': 0}), + 200, + headers: {'content-type': 'application/json'}, + ); + } + mediaStarts.add(req.url.queryParameters['StartIndex']); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 501; final end = start == 0 ? 500 : total; @@ -1711,7 +1730,7 @@ void main() { final items = await scoped.fetchFolderChildren('folder-1', onPage: pages.add); - expect(starts, ['0', '500']); + expect(mediaStarts, ['0', '500']); expect(items, hasLength(501)); // onPage surfaces accumulated items after intermediate pages only; the // final page is covered by the returned list.