perf(jellyfin): skip per-folder user data in folder tree listings
This commit is contained in:
+1
-1
@@ -70,4 +70,4 @@ duplication-report/
|
||||
**/fastlane/report.xml
|
||||
maestro/**/*.png
|
||||
|
||||
server/deploy.shscripts/framesync/
|
||||
server/deploy.sh
|
||||
@@ -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<List<MediaItem>> fetchFolderChildren(String folderId, {void Function(List<MediaItem> itemsSoFar)? onPage}) =>
|
||||
_fetchFolderChildren(folderId, onPage: onPage);
|
||||
|
||||
Future<List<MediaItem>> _fetchFolderChildren(
|
||||
String parentId, {
|
||||
void Function(List<MediaItem> 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<List<Map<String, dynamic>>> _pageFolderQuery(
|
||||
String parentId,
|
||||
Map<String, String> typeParams,
|
||||
String fields, {
|
||||
void Function(List<Map<String, dynamic>> 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 = <Map<String, dynamic>>[];
|
||||
final mappedSoFar = onPage == null ? null : <MediaItem>[];
|
||||
final out = <Map<String, dynamic>>[];
|
||||
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<String, dynamic>) {
|
||||
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<MediaItem>.unmodifiable(mappedSoFar));
|
||||
if (onRawPage != null && (totalRecordCount == null || startIndex < totalRecordCount)) {
|
||||
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) {
|
||||
final folderRank = (_isJellyfinFolderDto(a) ? 0 : 1).compareTo(_isJellyfinFolderDto(b) ? 0 : 1);
|
||||
|
||||
@@ -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)."
|
||||
@@ -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 = <Uri>[];
|
||||
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 = <String?>[];
|
||||
final mediaStarts = <String?>[];
|
||||
final pages = <List<MediaItem>>[];
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user