From 18aaa1b0aeae5a33407c5c485f6b677eb9725412 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Wed, 13 May 2026 18:41:23 +0100 Subject: [PATCH 1/7] add music_playlists_only pref, default on prep work. next commits filter My Playlists to only music ones, like the youtube android app does. --- plugin/Plugin.pm | 1 + plugin/Settings.pm | 2 +- plugin/strings.txt | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 43ed038..63b3574 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -63,6 +63,7 @@ $prefs->init({ yt_dlp => '', auto_update_ytdlp => 0, auto_update_check_hour => 3, + music_playlists_only => 1, }); tie my %recentlyPlayed, 'Tie::Cache::LRU', 50; diff --git a/plugin/Settings.pm b/plugin/Settings.pm index ef46c4a..591899d 100644 --- a/plugin/Settings.pm +++ b/plugin/Settings.pm @@ -14,7 +14,7 @@ my $log = logger('plugin.youtube'); my $cache = Slim::Utils::Cache->new(); my $prefs = preferences('plugin.youtube'); -my @bool = qw(live_edge aac vorbis opus use_video highres_icons auto_update_ytdlp); +my @bool = qw(live_edge aac vorbis opus use_video highres_icons auto_update_ytdlp music_playlists_only); sub name { return 'PLUGIN_YOUTUBE'; diff --git a/plugin/strings.txt b/plugin/strings.txt index d70ed16..0277b30 100644 --- a/plugin/strings.txt +++ b/plugin/strings.txt @@ -355,6 +355,18 @@ PLUGIN_YOUTUBE_PLAYLISTSORT DA Afspillelisteemner sorteret efter EN Playlist items sorted by +PLUGIN_YOUTUBE_MUSICONLY + CS Pouze hudební playlisty + DA Kun musik-spillelister + DE Nur Musik-Wiedergabelisten + EN Music playlists only + +PLUGIN_YOUTUBE_MUSICONLY_DESC + CS Zobrazit v "Mé playlisty" pouze playlisty s hudbou, stejně jako aplikace YouTube pro Android. + DA Vis kun musik-spillelister i "Mine spillelister", på samme måde som YouTube Android-appen gør. + DE In "Meine Wiedergabelisten" nur Musik-Wiedergabelisten zeigen, so wie es die YouTube Android-App macht. + EN Show only music playlists in "My Playlists", the way the YouTube Android app does. + PLUGIN_YOUTUBE_ICONRES CS Ikony s vysokým rozlišením DA Højopløste ikoner From c2c63d88991090d58b128734c735010992fbe3fc Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Wed, 13 May 2026 19:08:51 +0100 Subject: [PATCH 2/7] settings: add the music-only checkbox --- plugin/HTML/EN/plugins/YouTube/settings/basic.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugin/HTML/EN/plugins/YouTube/settings/basic.html b/plugin/HTML/EN/plugins/YouTube/settings/basic.html index 61638d1..3dc6615 100644 --- a/plugin/HTML/EN/plugins/YouTube/settings/basic.html +++ b/plugin/HTML/EN/plugins/YouTube/settings/basic.html @@ -159,6 +159,10 @@ [% END %] + [% WRAPPER setting title="PLUGIN_YOUTUBE_MUSICONLY" desc="PLUGIN_YOUTUBE_MUSICONLY_DESC" %] + + [% END %] + [% WRAPPER setting title="PLUGIN_YOUTUBE_MAXITEMS" desc="PLUGIN_YOUTUBE_MAXITEMS_DESC" %] [% END %] From fb60df7766a76c60303debb5a4bcd3a82e04f07e Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Wed, 13 May 2026 21:34:07 +0100 Subject: [PATCH 3/7] filter My Playlists by music category probe the first item of each unknown playlist, check its videoCategoryId, and only keep category 10 (Music). per-playlist decision goes into the cache so navigating back is free. if the video lookup fails (eg. bad api key or quota) we show all playlists. better than silently hiding everything. --- plugin/Plugin.pm | 104 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 63b3574..c36d745 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -376,7 +376,10 @@ sub myPlaylistHandler { }; Plugins::YouTube::API->searchDirect('playlists', sub { - $cb->( _renderList($_[0], 'title', $account) ); + my $result = $_[0]; + _filterMusicPlaylists($result, sub { + $cb->( _renderList($_[0], 'title', $account) ); + }); }, { %$account, _index => $args->{index}, @@ -384,6 +387,105 @@ sub myPlaylistHandler { }); } +# keep only playlists where the first item is in Music category (10). +# this is what the YouTube Music / Android app does. result for each +# playlist id is cached so back-navigation is free. +sub _filterMusicPlaylists { + my ($result, $cb) = @_; + + return $cb->($result) unless $prefs->get('music_playlists_only'); + return $cb->($result) unless $result && ref $result->{items} eq 'ARRAY'; + + my @items = @{$result->{items}}; + my @unknown; + for my $entry (@items) { + my $pid = $entry->{id} or next; + my $hit = $cache->get("yt:plmusic-$pid"); + $entry->{_musicScore} = $hit if defined $hit; + push @unknown, $entry unless defined $hit; + } + + my $finalize = sub { + my @kept = grep { ($_->{_musicScore} || 0) > 0 } @items; + delete $_->{_musicScore} for @items; + $result->{items} = \@kept; + $result->{total} = scalar @kept if defined $result->{total}; + $cb->($result); + }; + + return $finalize->() unless @unknown; + + _probeMusicPlaylists(\@unknown, $finalize); +} + +sub _probeMusicPlaylists { + my ($entries, $done) = @_; + my $remaining = scalar @$entries; + my %firstVideoForPlaylist; + my @videoIds; + + my $afterAllFirsts = sub { + unless (@videoIds) { + # nothing playable. mark them non-music for a bit and move on + for my $e (@$entries) { + $cache->set("yt:plmusic-" . $e->{id}, 0, 3600); + $e->{_musicScore} = 0; + } + return $done->(); + } + + Plugins::YouTube::API->getVideoDetails( sub { + my $vresult = shift; + + # api call died (bad key, quota, ...). dont silently hide every + # playlist. show them all and dont cache the decision. + if (!$vresult || $vresult->{error} || !ref $vresult->{items}) { + $log->warn("music filter: video lookup failed, showing all playlists"); + $_->{_musicScore} = 1 for @$entries; + return $done->(); + } + + my %catFor; + for my $v (@{$vresult->{items}}) { + $catFor{$v->{id}} = $v->{snippet}->{categoryId} || ''; + } + + for my $e (@$entries) { + my $vid = $firstVideoForPlaylist{$e->{id}}; + my $cat = $vid ? $catFor{$vid} : undef; + # 10 is Music. unknown vid (private/removed) counts as + # non-music but with shorter ttl so it can recover. + my $isMusic = (defined $cat && $cat eq '10') ? 1 : 0; + my $ttl = defined $cat ? 86400 : 3600; + $cache->set("yt:plmusic-" . $e->{id}, $isMusic, $ttl); + $e->{_musicScore} = $isMusic; + } + + $done->(); + }, join(',', @videoIds) ); + }; + + for my $entry (@$entries) { + my $pid = $entry->{id}; + Plugins::YouTube::API->searchDirect('playlistItems', sub { + my $items = $_[0]->{items} || []; + if (my $first = $items->[0]) { + my $vid = $first->{snippet}->{resourceId}->{videoId}; + if ($vid) { + $firstVideoForPlaylist{$pid} = $vid; + push @videoIds, $vid; + } + } + $afterAllFirsts->() if --$remaining == 0; + }, { + playlistId => $pid, + maxResults => 1, + _quantity => 1, + _cache_ttl => 86400, + }); + } +} + sub playlistHandler { my ($client, $cb, $args, $params) = @_; From 549aedf5225bcb271d8d0e547a26edc62eded3f2 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Tue, 19 May 2026 14:02:38 +0100 Subject: [PATCH 4/7] music filter: sample 5 items per playlist only checking the first item was too aggressive. workout playlists with a non-music intro got hidden. now we treat a playlist as music if any of the first 5 items is in category 10. also skip _pagedCall for the small sample fetch, and bump the cache key so old single-item decisions dont stick around. --- plugin/API.pm | 14 ++++++++++ plugin/Plugin.pm | 68 ++++++++++++++++++++++++++---------------------- 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/plugin/API.pm b/plugin/API.pm index 06aa2e2..63ceb3c 100644 --- a/plugin/API.pm +++ b/plugin/API.pm @@ -65,6 +65,20 @@ sub getVideoDetails { }, $cb); } +# single-shot fetch of the first N items of a playlist, no paging. used by +# the music-playlist filter where we just want a small sample to classify. +sub getPlaylistSample { + my ( $class, $cb, $playlistId, $max ) = @_; + + _call('playlistItems', { + part => 'snippet', + playlistId => $playlistId, + maxResults => $max || 5, + _noRegion => 1, + _cache_ttl => 86400, + }, $cb); +} + sub _pagedCall { my ( $method, $args, $cb ) = @_; my $wantedItems = $args->{_quantity} || $prefs->get('max_items'); diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index c36d745..4611c7f 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -387,9 +387,11 @@ sub myPlaylistHandler { }); } -# keep only playlists where the first item is in Music category (10). -# this is what the YouTube Music / Android app does. result for each -# playlist id is cached so back-navigation is free. +# keep playlists where any of the sampled items is in Music category +# (10). same idea as the YouTube Music / Android app menu. we look at +# more than one item because the first one is often an intro, ad, or +# just badly tagged. decision per playlist id goes into the cache so +# we dont re-probe on every navigation. sub _filterMusicPlaylists { my ($result, $cb) = @_; @@ -400,7 +402,7 @@ sub _filterMusicPlaylists { my @unknown; for my $entry (@items) { my $pid = $entry->{id} or next; - my $hit = $cache->get("yt:plmusic-$pid"); + my $hit = $cache->get("yt:plmusic2-$pid"); $entry->{_musicScore} = $hit if defined $hit; push @unknown, $entry unless defined $hit; } @@ -418,17 +420,20 @@ sub _filterMusicPlaylists { _probeMusicPlaylists(\@unknown, $finalize); } +use constant MUSIC_SAMPLE_SIZE => 5; + sub _probeMusicPlaylists { my ($entries, $done) = @_; my $remaining = scalar @$entries; - my %firstVideoForPlaylist; - my @videoIds; + my %vidsForPlaylist; + my %seenVid; + my @allVideoIds; - my $afterAllFirsts = sub { - unless (@videoIds) { + my $afterAllSamples = sub { + unless (@allVideoIds) { # nothing playable. mark them non-music for a bit and move on for my $e (@$entries) { - $cache->set("yt:plmusic-" . $e->{id}, 0, 3600); + $cache->set("yt:plmusic2-" . $e->{id}, 0, 3600); $e->{_musicScore} = 0; } return $done->(); @@ -451,38 +456,39 @@ sub _probeMusicPlaylists { } for my $e (@$entries) { - my $vid = $firstVideoForPlaylist{$e->{id}}; - my $cat = $vid ? $catFor{$vid} : undef; - # 10 is Music. unknown vid (private/removed) counts as - # non-music but with shorter ttl so it can recover. - my $isMusic = (defined $cat && $cat eq '10') ? 1 : 0; - my $ttl = defined $cat ? 86400 : 3600; - $cache->set("yt:plmusic-" . $e->{id}, $isMusic, $ttl); + my $vids = $vidsForPlaylist{$e->{id}} || []; + my $known = 0; + my $isMusic = 0; + for my $vid (@$vids) { + my $cat = $catFor{$vid}; + next unless defined $cat; + $known++; + if ($cat eq '10') { $isMusic = 1; last } + } + # at least one music sample = music. all known and none + # music = not music. nothing known = short ttl so we recover. + my $ttl = $known ? 86400 : 3600; + $cache->set("yt:plmusic2-" . $e->{id}, $isMusic, $ttl); $e->{_musicScore} = $isMusic; } $done->(); - }, join(',', @videoIds) ); + }, join(',', @allVideoIds) ); }; for my $entry (@$entries) { my $pid = $entry->{id}; - Plugins::YouTube::API->searchDirect('playlistItems', sub { + Plugins::YouTube::API->getPlaylistSample( sub { my $items = $_[0]->{items} || []; - if (my $first = $items->[0]) { - my $vid = $first->{snippet}->{resourceId}->{videoId}; - if ($vid) { - $firstVideoForPlaylist{$pid} = $vid; - push @videoIds, $vid; - } + my @vids; + for my $it (@$items) { + my $vid = $it->{snippet}->{resourceId}->{videoId} or next; + push @vids, $vid; + push @allVideoIds, $vid unless $seenVid{$vid}++; } - $afterAllFirsts->() if --$remaining == 0; - }, { - playlistId => $pid, - maxResults => 1, - _quantity => 1, - _cache_ttl => 86400, - }); + $vidsForPlaylist{$pid} = \@vids; + $afterAllSamples->() if --$remaining == 0; + }, $pid, MUSIC_SAMPLE_SIZE ); } } From 324395e810dfeb129468cbfa5c137cffa0fc5592 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Tue, 19 May 2026 15:24:18 +0100 Subject: [PATCH 5/7] music filter: pass oauth token to sample fetch without the token, playlistItems.list gets 404 playlistNotFound for any non-public playlist. only the public ones (EDM, Konzentration, Lounge) survived. everything else got treated as non-music. bump the cache key again, old broken decisions still sit there for an hour. --- plugin/API.pm | 10 +++++++--- plugin/Plugin.pm | 22 ++++++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/plugin/API.pm b/plugin/API.pm index 63ceb3c..81b4341 100644 --- a/plugin/API.pm +++ b/plugin/API.pm @@ -67,16 +67,20 @@ sub getVideoDetails { # single-shot fetch of the first N items of a playlist, no paging. used by # the music-playlist filter where we just want a small sample to classify. +# pass extra (eg. access_token + _noKey) to read private playlists. sub getPlaylistSample { - my ( $class, $cb, $playlistId, $max ) = @_; + my ( $class, $cb, $playlistId, $max, $extra ) = @_; - _call('playlistItems', { + my %args = ( part => 'snippet', playlistId => $playlistId, maxResults => $max || 5, _noRegion => 1, _cache_ttl => 86400, - }, $cb); + %{ $extra || {} }, + ); + + _call('playlistItems', \%args, $cb); } sub _pagedCall { diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 4611c7f..b8d0b06 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -375,9 +375,15 @@ sub myPlaylistHandler { access_token => $cache->get('yt:access_token'), }; + # auth bits the sample probe needs to see private playlists + my $probeAuth = { + _noKey => 1, + access_token => $cache->get('yt:access_token'), + }; + Plugins::YouTube::API->searchDirect('playlists', sub { my $result = $_[0]; - _filterMusicPlaylists($result, sub { + _filterMusicPlaylists($result, $probeAuth, sub { $cb->( _renderList($_[0], 'title', $account) ); }); }, { @@ -393,7 +399,7 @@ sub myPlaylistHandler { # just badly tagged. decision per playlist id goes into the cache so # we dont re-probe on every navigation. sub _filterMusicPlaylists { - my ($result, $cb) = @_; + my ($result, $probeAuth, $cb) = @_; return $cb->($result) unless $prefs->get('music_playlists_only'); return $cb->($result) unless $result && ref $result->{items} eq 'ARRAY'; @@ -402,7 +408,7 @@ sub _filterMusicPlaylists { my @unknown; for my $entry (@items) { my $pid = $entry->{id} or next; - my $hit = $cache->get("yt:plmusic2-$pid"); + my $hit = $cache->get("yt:plmusic3-$pid"); $entry->{_musicScore} = $hit if defined $hit; push @unknown, $entry unless defined $hit; } @@ -417,13 +423,13 @@ sub _filterMusicPlaylists { return $finalize->() unless @unknown; - _probeMusicPlaylists(\@unknown, $finalize); + _probeMusicPlaylists(\@unknown, $probeAuth, $finalize); } use constant MUSIC_SAMPLE_SIZE => 5; sub _probeMusicPlaylists { - my ($entries, $done) = @_; + my ($entries, $probeAuth, $done) = @_; my $remaining = scalar @$entries; my %vidsForPlaylist; my %seenVid; @@ -433,7 +439,7 @@ sub _probeMusicPlaylists { unless (@allVideoIds) { # nothing playable. mark them non-music for a bit and move on for my $e (@$entries) { - $cache->set("yt:plmusic2-" . $e->{id}, 0, 3600); + $cache->set("yt:plmusic3-" . $e->{id}, 0, 3600); $e->{_musicScore} = 0; } return $done->(); @@ -468,7 +474,7 @@ sub _probeMusicPlaylists { # at least one music sample = music. all known and none # music = not music. nothing known = short ttl so we recover. my $ttl = $known ? 86400 : 3600; - $cache->set("yt:plmusic2-" . $e->{id}, $isMusic, $ttl); + $cache->set("yt:plmusic3-" . $e->{id}, $isMusic, $ttl); $e->{_musicScore} = $isMusic; } @@ -488,7 +494,7 @@ sub _probeMusicPlaylists { } $vidsForPlaylist{$pid} = \@vids; $afterAllSamples->() if --$remaining == 0; - }, $pid, MUSIC_SAMPLE_SIZE ); + }, $pid, MUSIC_SAMPLE_SIZE, $probeAuth ); } } From d8e0943ce192bee02cb2aa4695d8bfb8e858e495 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Tue, 19 May 2026 16:11:42 +0100 Subject: [PATCH 6/7] music filter: chunk videos.list at 50 ids up to 50 playlists times 5 sampled ids each = 250 video ids. one big videos.list call hits the api 50-id cap and 400's. the whole batch fails so we fall back to "show all playlists", and Kochen, 3D Druck etc come back into the menu. split into chunks of 50, fire them in parallel, merge categories from each chunk. only treat as full failure if every chunk fails. also add a small log line so this is easier to verify next time. --- plugin/Plugin.pm | 47 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index b8d0b06..1d2d34f 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -408,13 +408,16 @@ sub _filterMusicPlaylists { my @unknown; for my $entry (@items) { my $pid = $entry->{id} or next; - my $hit = $cache->get("yt:plmusic3-$pid"); + my $hit = $cache->get("yt:plmusic4-$pid"); $entry->{_musicScore} = $hit if defined $hit; push @unknown, $entry unless defined $hit; } + main::INFOLOG && $log->is_info && $log->info("music filter: " . scalar(@items) . " playlists, " . scalar(@unknown) . " to probe"); + my $finalize = sub { my @kept = grep { ($_->{_musicScore} || 0) > 0 } @items; + main::INFOLOG && $log->is_info && $log->info("music filter: kept " . scalar(@kept) . "/" . scalar(@items)); delete $_->{_musicScore} for @items; $result->{items} = \@kept; $result->{total} = scalar @kept if defined $result->{total}; @@ -439,28 +442,27 @@ sub _probeMusicPlaylists { unless (@allVideoIds) { # nothing playable. mark them non-music for a bit and move on for my $e (@$entries) { - $cache->set("yt:plmusic3-" . $e->{id}, 0, 3600); + $cache->set("yt:plmusic4-" . $e->{id}, 0, 3600); $e->{_musicScore} = 0; } return $done->(); } - Plugins::YouTube::API->getVideoDetails( sub { - my $vresult = shift; + # videos.list caps at 50 ids per call. chunk and fan out. + my @chunks; + push @chunks, [ splice(@allVideoIds, 0, 50) ] while @allVideoIds; + my $pending = scalar @chunks; + my %catFor; + my $hadError = 0; - # api call died (bad key, quota, ...). dont silently hide every - # playlist. show them all and dont cache the decision. - if (!$vresult || $vresult->{error} || !ref $vresult->{items}) { + my $finish = sub { + if ($hadError && !keys %catFor) { + # every chunk died. dont silently hide all playlists, show them. $log->warn("music filter: video lookup failed, showing all playlists"); $_->{_musicScore} = 1 for @$entries; return $done->(); } - my %catFor; - for my $v (@{$vresult->{items}}) { - $catFor{$v->{id}} = $v->{snippet}->{categoryId} || ''; - } - for my $e (@$entries) { my $vids = $vidsForPlaylist{$e->{id}} || []; my $known = 0; @@ -469,17 +471,32 @@ sub _probeMusicPlaylists { my $cat = $catFor{$vid}; next unless defined $cat; $known++; - if ($cat eq '10') { $isMusic = 1; last } + if ($cat eq '10') { $isMusic = 1 } } # at least one music sample = music. all known and none # music = not music. nothing known = short ttl so we recover. my $ttl = $known ? 86400 : 3600; - $cache->set("yt:plmusic3-" . $e->{id}, $isMusic, $ttl); + $cache->set("yt:plmusic4-" . $e->{id}, $isMusic, $ttl); $e->{_musicScore} = $isMusic; } $done->(); - }, join(',', @allVideoIds) ); + }; + + for my $chunk (@chunks) { + Plugins::YouTube::API->getVideoDetails( sub { + my $vresult = shift; + if (!$vresult || $vresult->{error} || !ref $vresult->{items}) { + $log->warn("music filter: chunk lookup failed"); + $hadError = 1; + } else { + for my $v (@{$vresult->{items}}) { + $catFor{$v->{id}} = $v->{snippet}->{categoryId} || ''; + } + } + $finish->() if --$pending == 0; + }, join(',', @$chunk) ); + } }; for my $entry (@$entries) { From 775609ef2d599bb145b38c77229704ee55b896db Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Tue, 19 May 2026 17:42:38 +0100 Subject: [PATCH 7/7] music filter: always fetch the full set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OPML level-2 only fetches one row at a given index. with the filter on we pulled the first 50 raw playlists, filtered down to 18, then indexed into that. position 12 ended up pointing at a different playlist than the menu showed, so Workout opened raining tracks. position 20 fell off the end, so 💔 looked empty. now we ask for max_items upstream whenever the filter is on, so the filtered set is stable no matter which index OPML is asking for. --- plugin/Plugin.pm | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 1d2d34f..11928f6 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -381,6 +381,15 @@ sub myPlaylistHandler { access_token => $cache->get('yt:access_token'), }; + # when the filter is on we have to grab the full set up front. + # OPML level-2 calls us with quantity=1, index=N. if we only fetch + # the first page, then filter, the row at index N points at a + # different playlist than what the menu showed, or just falls off + # the end (looks empty). override index and quantity in that case. + my $filterOn = $prefs->get('music_playlists_only'); + my $fetchIndex = $filterOn ? 0 : $args->{index}; + my $fetchQuantity = $filterOn ? $prefs->get('max_items') : $args->{quantity}; + Plugins::YouTube::API->searchDirect('playlists', sub { my $result = $_[0]; _filterMusicPlaylists($result, $probeAuth, sub { @@ -388,8 +397,8 @@ sub myPlaylistHandler { }); }, { %$account, - _index => $args->{index}, - _quantity => $args->{quantity}, + _index => $fetchIndex, + _quantity => $fetchQuantity, }); }