perf(jellyfin): skip per-folder user data in folder tree listings

This commit is contained in:
edde746
2026-06-12 02:18:28 +02:00
parent d816d82a38
commit 0f1bf5e011
4 changed files with 121 additions and 197 deletions
+1 -1
View File
@@ -70,4 +70,4 @@ duplication-report/
**/fastlane/report.xml **/fastlane/report.xml
maestro/**/*.png maestro/**/*.png
server/deploy.shscripts/framesync/ server/deploy.sh
+72 -24
View File
@@ -33,15 +33,22 @@ const _browseFields = 'RecursiveItemCount,ChildCount,UserData,PremiereDate,Origi
/// queries because it is the heaviest item field Jellyfin returns. /// queries because it is the heaviest item field Jellyfin returns.
const _episodeRowFields = '$_browseFields,MediaSources'; const _episodeRowFields = '$_browseFields,MediaSources';
/// Folder-tree field set. The tree renders title/thumb/watch state plus /// Folder-tree field set for MEDIA children. The tree renders
/// default dto fields (year, runtime, ratings); it deliberately skips /// title/thumb/watch state plus default dto fields (year, runtime, ratings);
/// `RecursiveItemCount`/`ChildCount` — per-item COUNT queries the server /// it deliberately skips `RecursiveItemCount`/`ChildCount` — per-item COUNT
/// runs for every folder/series row, which made large folder listings very /// queries the server runs for every folder/series row, which made large
/// slow — and `Overview`, which the tree never shows. Jellyfin web's folder /// folder listings very slow — and `Overview`, which the tree never shows.
/// view requests none of them either. The unwatched badge survives via /// Jellyfin web's folder view requests none of them either. The unwatched
/// `UserData.UnplayedItemCount` ([MediaItem.unwatchedCount] fallback). /// badge survives via `UserData.UnplayedItemCount`
/// ([MediaItem.unwatchedCount] fallback).
const _folderBrowseFields = 'UserData,PremiereDate,OriginalTitle,SortName'; 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 /// Even slimmer set used by [fetchClientSideEpisodeQueue]. Queue rows
/// only need title, thumbnail (`ImageTags['Primary']`), season/episode /// only need title, thumbnail (`ImageTags['Primary']`), season/episode
/// index, and watched state. Title + indices come back without any /// index, and watched state. Title + indices come back without any
@@ -631,18 +638,16 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
Future<List<MediaItem>> fetchFolderChildren(String folderId, {void Function(List<MediaItem> itemsSoFar)? onPage}) => Future<List<MediaItem>> fetchFolderChildren(String folderId, {void Function(List<MediaItem> itemsSoFar)? onPage}) =>
_fetchFolderChildren(folderId, onPage: onPage); _fetchFolderChildren(folderId, onPage: onPage);
Future<List<MediaItem>> _fetchFolderChildren( /// Page through `/Items?ParentId=...&Recursive=false` with the given type
String parentId, { /// filter. [onRawPage] receives the accumulated rows after each intermediate
void Function(List<MediaItem> itemsSoFar)? onPage, /// page (never for single-page listings or the final page).
Future<List<Map<String, dynamic>>> _pageFolderQuery(
String parentId,
Map<String, String> typeParams,
String fields, {
void Function(List<Map<String, dynamic>> rowsSoFar)? onRawPage,
}) async { }) async {
final cacheKey = '/Items?ParentId=$parentId&Recursive=false&userId=${connection.userId}'; final out = <Map<String, dynamic>>[];
if (isOfflineMode) {
final cached = await cache.get(ServerId(cacheServerId), cacheKey);
return cached == null ? const [] : _mapItems(_itemsArray(cached));
}
final allRaw = <Map<String, dynamic>>[];
final mappedSoFar = onPage == null ? null : <MediaItem>[];
var startIndex = 0; var startIndex = 0;
int? totalRecordCount; int? totalRecordCount;
while (totalRecordCount == null || startIndex < totalRecordCount) { while (totalRecordCount == null || startIndex < totalRecordCount) {
@@ -655,27 +660,70 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
'StartIndex': '$startIndex', 'StartIndex': '$startIndex',
'Limit': '$_childrenPageSize', 'Limit': '$_childrenPageSize',
'EnableTotalRecordCount': 'true', 'EnableTotalRecordCount': 'true',
'SortBy': 'IsFolder,SortName', 'SortBy': 'SortName',
'SortOrder': 'Ascending', 'SortOrder': 'Ascending',
'Fields': _folderBrowseFields, 'Fields': fields,
...typeParams,
...jellyfinImageQueryParameters, ...jellyfinImageQueryParameters,
}, },
); );
throwIfHttpError(response); throwIfHttpError(response);
final data = response.data; final data = response.data;
final page = _itemsArray(data); final page = _itemsArray(data);
allRaw.addAll(page); out.addAll(page);
mappedSoFar?.addAll(_mapItems(page));
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final rawTotal = data['TotalRecordCount']; final rawTotal = data['TotalRecordCount'];
if (rawTotal is int) totalRecordCount = rawTotal; if (rawTotal is int) totalRecordCount = rawTotal;
} }
if (page.isEmpty || page.length < _childrenPageSize) break; if (page.isEmpty || page.length < _childrenPageSize) break;
startIndex += page.length; startIndex += page.length;
if (mappedSoFar != null && (totalRecordCount == null || startIndex < totalRecordCount)) { if (onRawPage != null && (totalRecordCount == null || startIndex < totalRecordCount)) {
onPage!(List<MediaItem>.unmodifiable(mappedSoFar)); onRawPage(out);
} }
} }
return out;
}
Future<List<MediaItem>> _fetchFolderChildren(
String parentId, {
void Function(List<MediaItem> 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<Map<String, dynamic>>? 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<MediaItem>.unmodifiable(_mapItems([...folders, ...rowsSoFar])));
},
);
final results = await Future.wait([foldersFuture, mediaFuture]);
final allRaw = <Map<String, dynamic>>[...results[0], ...results[1]];
allRaw.sort((a, b) { allRaw.sort((a, b) {
final folderRank = (_isJellyfinFolderDto(a) ? 0 : 1).compareTo(_isJellyfinFolderDto(b) ? 0 : 1); final folderRank = (_isJellyfinFolderDto(a) ? 0 : 1).compareTo(_isJellyfinFolderDto(b) ? 0 : 1);
-143
View File
@@ -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)."
+48 -29
View File
@@ -1639,22 +1639,22 @@ void main() {
expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode'); expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode');
}); });
test('fetchLibraryFolders uses direct non-recursive Items query and orders folders first', () async { test('fetchLibraryFolders splits folder/media queries and orders folders first', () async {
Uri? captured; 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 = <Uri>[];
final scoped = JellyfinClient.forTesting( final scoped = JellyfinClient.forTesting(
connection: _conn(), connection: _conn(),
httpClient: MockClient((req) async { 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( return http.Response(
jsonEncode({ jsonEncode({'Items': items, 'TotalRecordCount': items.length}),
'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, 200,
headers: {'content-type': 'application/json'}, headers: {'content-type': 'application/json'},
); );
@@ -1664,20 +1664,31 @@ void main() {
final items = await scoped.fetchLibraryFolders('lib-1'); final items = await scoped.fetchLibraryFolders('lib-1');
expect(captured, isNotNull); expect(captured, hasLength(2));
expect(captured!.path, '/Items'); final folderQuery = captured.firstWhere((u) => u.queryParameters.containsKey('IncludeItemTypes'));
expect(captured!.queryParameters['ParentId'], 'lib-1'); final mediaQuery = captured.firstWhere((u) => u.queryParameters.containsKey('ExcludeItemTypes'));
expect(captured!.queryParameters['Recursive'], 'false'); for (final uri in [folderQuery, mediaQuery]) {
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); expect(uri.path, '/Items');
expect(captured!.queryParameters['SortBy'], 'IsFolder,SortName'); expect(uri.queryParameters['ParentId'], 'lib-1');
expect(captured!.queryParameters['SortOrder'], 'Ascending'); expect(uri.queryParameters['Recursive'], 'false');
expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources'))); expect(uri.queryParameters['EnableTotalRecordCount'], 'true');
// Folder listings use the slim field set: the per-item count fields are expect(uri.queryParameters['SortBy'], 'SortName');
// expensive server-side and Overview is never rendered in the tree. expect(uri.queryParameters['SortOrder'], 'Ascending');
expect(captured!.queryParameters['Fields'], isNot(contains('RecursiveItemCount'))); // Slim field sets: per-item count fields are expensive server-side
expect(captured!.queryParameters['Fields'], isNot(contains('ChildCount'))); // and Overview is never rendered in the tree.
expect(captured!.queryParameters['Fields'], isNot(contains('Overview'))); expect(uri.queryParameters['Fields'], isNot(contains('MediaSources')));
expect(captured!.queryParameters['Fields'], contains('UserData')); 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.map((item) => item.id), ['folder-z', 'series-a', 'movie-m', 'track-z']);
expect(items.first.kind, MediaKind.unknown); expect(items.first.kind, MediaKind.unknown);
expect(items.first.raw?['IsFolder'], isTrue); expect(items.first.raw?['IsFolder'], isTrue);
@@ -1685,12 +1696,20 @@ void main() {
}); });
test('fetchFolderChildren pages direct folder contents', () async { test('fetchFolderChildren pages direct folder contents', () async {
final starts = <String?>[]; final mediaStarts = <String?>[];
final pages = <List<MediaItem>>[]; final pages = <List<MediaItem>>[];
final scoped = JellyfinClient.forTesting( final scoped = JellyfinClient.forTesting(
connection: _conn(), connection: _conn(),
httpClient: MockClient((req) async { 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'); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0');
const total = 501; const total = 501;
final end = start == 0 ? 500 : total; final end = start == 0 ? 500 : total;
@@ -1711,7 +1730,7 @@ void main() {
final items = await scoped.fetchFolderChildren('folder-1', onPage: pages.add); final items = await scoped.fetchFolderChildren('folder-1', onPage: pages.add);
expect(starts, ['0', '500']); expect(mediaStarts, ['0', '500']);
expect(items, hasLength(501)); expect(items, hasLength(501));
// onPage surfaces accumulated items after intermediate pages only; the // onPage surfaces accumulated items after intermediate pages only; the
// final page is covered by the returned list. // final page is covered by the returned list.