diff --git a/plugin/API.pm b/plugin/API.pm index 06aa2e2..81b4341 100644 --- a/plugin/API.pm +++ b/plugin/API.pm @@ -65,6 +65,24 @@ 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. +# pass extra (eg. access_token + _noKey) to read private playlists. +sub getPlaylistSample { + my ( $class, $cb, $playlistId, $max, $extra ) = @_; + + my %args = ( + part => 'snippet', + playlistId => $playlistId, + maxResults => $max || 5, + _noRegion => 1, + _cache_ttl => 86400, + %{ $extra || {} }, + ); + + _call('playlistItems', \%args, $cb); +} + sub _pagedCall { my ( $method, $args, $cb ) = @_; my $wantedItems = $args->{_quantity} || $prefs->get('max_items'); 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 %] diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 43ed038..11928f6 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; @@ -374,15 +375,155 @@ 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'), + }; + + # 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 { - $cb->( _renderList($_[0], 'title', $account) ); + my $result = $_[0]; + _filterMusicPlaylists($result, $probeAuth, sub { + $cb->( _renderList($_[0], 'title', $account) ); + }); }, { %$account, - _index => $args->{index}, - _quantity => $args->{quantity}, + _index => $fetchIndex, + _quantity => $fetchQuantity, }); } +# 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, $probeAuth, $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: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}; + $cb->($result); + }; + + return $finalize->() unless @unknown; + + _probeMusicPlaylists(\@unknown, $probeAuth, $finalize); +} + +use constant MUSIC_SAMPLE_SIZE => 5; + +sub _probeMusicPlaylists { + my ($entries, $probeAuth, $done) = @_; + my $remaining = scalar @$entries; + my %vidsForPlaylist; + my %seenVid; + my @allVideoIds; + + my $afterAllSamples = sub { + unless (@allVideoIds) { + # nothing playable. mark them non-music for a bit and move on + for my $e (@$entries) { + $cache->set("yt:plmusic4-" . $e->{id}, 0, 3600); + $e->{_musicScore} = 0; + } + return $done->(); + } + + # 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; + + 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->(); + } + + for my $e (@$entries) { + 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 } + } + # 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:plmusic4-" . $e->{id}, $isMusic, $ttl); + $e->{_musicScore} = $isMusic; + } + + $done->(); + }; + + 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) { + my $pid = $entry->{id}; + Plugins::YouTube::API->getPlaylistSample( sub { + my $items = $_[0]->{items} || []; + my @vids; + for my $it (@$items) { + my $vid = $it->{snippet}->{resourceId}->{videoId} or next; + push @vids, $vid; + push @allVideoIds, $vid unless $seenVid{$vid}++; + } + $vidsForPlaylist{$pid} = \@vids; + $afterAllSamples->() if --$remaining == 0; + }, $pid, MUSIC_SAMPLE_SIZE, $probeAuth ); + } +} + sub playlistHandler { my ($client, $cb, $args, $params) = @_; 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