From d22b940dde9225cb4889467d5409db0504d3124f Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Sat, 2 May 2026 14:23:11 +0100 Subject: [PATCH 01/21] fix: stop sending mine=true into playlistItems problem was: open "my playlists", click any list, get items from a totally different one. happens because mine=true was kept in the per-item passthrough. when LMS then asked playlistItems.list for one specific playlist, mine=true was still attached and YT returned whatever first matched the auth context, not the requested playlistId. mine is only valid on playlists.list. keep it there, drop it from the per-item bag, and also delete it again in playlistHandler so we cant trip on it later. --- plugin/Plugin.pm | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 43ed038..abf18b2 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -366,20 +366,24 @@ sub myPlaylistHandler { delete $params->{count}; - # need to passthrough the personal account items - my $account = { - _cache_ttl => 60, + # mine=true is for playlists.list only. dont put it in the per-item + # passthrough or playlistItems.list will get it later and load the + # wrong playlist. + my $token = $cache->get('yt:access_token'); + my $itemPassthrough = { _noKey => 1, - mine => 'true', - access_token => $cache->get('yt:access_token'), + access_token => $token, }; Plugins::YouTube::API->searchDirect('playlists', sub { - $cb->( _renderList($_[0], 'title', $account) ); + $cb->( _renderList($_[0], 'title', $itemPassthrough) ); }, { - %$account, - _index => $args->{index}, - _quantity => $args->{quantity}, + _cache_ttl => 60, + _noKey => 1, + mine => 'true', + access_token => $token, + _index => $args->{index}, + _quantity => $args->{quantity}, }); } @@ -390,6 +394,10 @@ sub playlistHandler { $params->{_quantity} = $args->{quantity}; $params->{_cache_ttl} = $prefs->get('cache_ttl'); + # extra paranoia. mine belongs to playlists.list, never to playlistItems.list. + # if it slips in here YT returns the wrong playlist. + delete $params->{mine}; + Plugins::YouTube::API->searchDirect('playlistItems', sub { $cb->( _renderList($_[0], $prefs->get('playlist_sort')) ); }, $params); From a817dfc8fe27e09c1569712083e3ee3f8839bcd3 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Sat, 9 May 2026 11:42:38 +0100 Subject: [PATCH 02/21] webm: resync parser, dont kill the stream EBML element bigger than MAX_EBML used to return WEBM_ERROR. callers ignore the return value so playback just stops a few seconds in. people see it as "song cuts off early". new behaviour: scan the buffer for the next CLUSTER id and pick up there. if no cluster boundary is in the buffer yet, drop most of the buffer and wait for more bytes. we might lose a fragment but the song keeps going, which is what users want. --- plugin/WebM.pm | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/plugin/WebM.pm b/plugin/WebM.pm index 4a8dab8..fbafcbd 100644 --- a/plugin/WebM.pm +++ b/plugin/WebM.pm @@ -228,22 +228,41 @@ sub getAudio { my $id; my $size; - # first need to acquired an ID + # first need to acquired an ID if ( !defined $v->{id} ) { $v->{position} += getEBML(\$v->{inBuf}, \$id, \$size); - + # skip id that are not CLUSTER *except* the SEGMENT ! main::DEBUGLOG && $log->is_debug && $log->debug("cluster of $size byte (last audio $v->{timecode} ms)") if ($id eq ID_CLUSTER); next if $id eq ID_CLUSTER || $id eq ID_SEGMENT; - - $v->{need} = $size; + + $v->{need} = $size; $v->{id} = $id; - + + # parser desync. YT sometimes drops garbage in the stream. + # look for next CLUSTER id and continue from there. better + # than killing the whole song. if ($size > MAX_EBML) { - $log->error("EBML too large: $size"); - return WEBM_ERROR; + $log->warn("EBML too large: $size, attempting resync"); + my $sync = index($v->{inBuf}, ID_CLUSTER); + if ($sync >= 0) { + substr($v->{inBuf}, 0, $sync, ''); + $v->{position} += $sync; + $v->{need} = EBML_NEED; + undef $v->{id}; + next; + } + # no cluster in buffer yet. drop most of it, ask for more + my $drop = length($v->{inBuf}) - length(ID_CLUSTER) + 1; + if ($drop > 0) { + substr($v->{inBuf}, 0, $drop, ''); + $v->{position} += $drop; + } + $v->{need} = EBML_NEED; + undef $v->{id}; + return WEBM_MORE; } - + next; } From 18aaa1b0aeae5a33407c5c485f6b677eb9725412 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Wed, 13 May 2026 18:41:23 +0100 Subject: [PATCH 03/21] 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 04/21] 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 05/21] 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 28be571d61c447349906b325f20b5ed230e80c86 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Sat, 16 May 2026 19:08:54 +0100 Subject: [PATCH 06/21] back off bulk metadata when API fails getMetadataFor runs on every UI poll. for each uncached track in the playlist we collected ids and called the data api. when the call failed (bad key, quota, private video, network blip, whatever) nothing was cached, so the next poll did the same thing again. a long playlist turns this into a request storm that drags the whole server down. cache a tiny fail marker per id. 5 min for hard errors, 1 day for ids the api never returns (most likely deleted or private). next poll skips them so we stop hammering. --- plugin/ProtocolHandler.pm | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/plugin/ProtocolHandler.pm b/plugin/ProtocolHandler.pm index 15ac0e0..356a65c 100644 --- a/plugin/ProtocolHandler.pm +++ b/plugin/ProtocolHandler.pm @@ -1156,7 +1156,8 @@ sub getMetadataFor { if ( $trackURL =~ m{youtube:/*(.+)} ) { my $trackId = $class->getId($trackURL); - if ( $trackId && !$cache->get("yt:meta-$trackId") ) { + # skip ids we already gave up on, dont hammer the api + if ( $trackId && !$cache->get("yt:meta-$trackId") && !$cache->get("yt:meta-fail-$trackId") ) { push @need, $trackId; } elsif (!$trackId) { $log->warn("No id found: $trackURL"); @@ -1170,7 +1171,7 @@ sub getMetadataFor { if (scalar @need && !$abort) { my $list = join( ',', @need ); main::INFOLOG && $log->is_info && $log->info( "Need to fetch metadata for: $list"); - _getBulkMetadata($client, $pageCall, $list); + _getBulkMetadata($client, $pageCall, $list, \@need); } else { $client->master->pluginData(fetchingYTMeta => 0); unless ($abort) { @@ -1198,13 +1199,19 @@ sub getMetadataFor { } sub _getBulkMetadata { - my ($client, $cb, $ids) = @_; + my ($client, $cb, $ids, $requestedIds) = @_; Plugins::YouTube::API->getVideoDetails( sub { my $result = shift; if ( !$result || $result->{error} || !$result->{pageInfo}->{totalResults} || !scalar @{$result->{items}} ) { $log->error($result->{error} || 'Failed to grab track information'); + # mark these failed for 5 min. enough to break the request storm, + # short enough that a fixed api key or transient blip recovers + # soon after. + if ($requestedIds) { + $cache->set("yt:meta-fail-$_", 1, 300) for @$requestedIds; + } $cb->(1) if defined $cb; return; } @@ -1241,6 +1248,15 @@ sub _getBulkMetadata { $cache->set("yt:meta-" . $item->{id}, $meta, 86400); } + # ids we asked for but didnt get back are usually deleted or private. + # cache as failed for a day so the UI poll stops asking. + if ($requestedIds) { + my %got = map { $_->{id} => 1 } @{$result->{items}}; + for my $id (@$requestedIds) { + $cache->set("yt:meta-fail-$id", 1, 86400) unless $got{$id}; + } + } + $cb->() if defined $cb; }, $ids); From 549aedf5225bcb271d8d0e547a26edc62eded3f2 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Tue, 19 May 2026 14:02:38 +0100 Subject: [PATCH 07/21] 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 08/21] 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 09/21] 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 10/21] 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, }); } From d7b367a5bd33833d9c843f0d75bf23a107663c49 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Tue, 19 May 2026 18:11:23 +0200 Subject: [PATCH 11/21] api: ask for topicDetails too --- plugin/API.pm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin/API.pm b/plugin/API.pm index 81b4341..3f2e411 100644 --- a/plugin/API.pm +++ b/plugin/API.pm @@ -57,8 +57,9 @@ sub getCategories { sub getVideoDetails { my ( $class, $cb, $ids ) = @_; + # topicDetails for genre _call('videos', { - part => 'snippet,contentDetails', + part => 'snippet,contentDetails,topicDetails', id => $ids, # cache video details a bit longer _cache_ttl => 7 * 86400, From 56d406984103b6e5f8aba91b7ec0ceac067f311f Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Tue, 19 May 2026 18:48:07 +0200 Subject: [PATCH 12/21] add Metadata.pm helpers --- plugin/Metadata.pm | 153 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 plugin/Metadata.pm diff --git a/plugin/Metadata.pm b/plugin/Metadata.pm new file mode 100644 index 0000000..52f2c7e --- /dev/null +++ b/plugin/Metadata.pm @@ -0,0 +1,153 @@ +package Plugins::YouTube::Metadata; + +# helpers for v3 video items. v3 has no album/year/artist fields, +# we dig artist/album/year out of the description block when YT +# Music auto-generated it. + +use strict; +use warnings; + +use Slim::Utils::Log; + +my $log = logger('plugin.youtube'); + +use constant CATEGORY_MUSIC => 10; + +# "Artist - Title" split. returns (artist, title, fulltitle) or +# (undef, $title, undef). +sub parseDashTitle { + my ($title) = @_; + return (undef, $title, undef) unless defined $title; + + if ($title =~ /^(.+?)\s+-\s+(.+)$/) { + return ($1, $2, $title); + } + return (undef, $title, undef); +} + +# the auto-generated YT Music "Provided to YouTube by..." block. +# shape: +# Provided to YouTube by +# . <artist1> . <artist2> +# <album> +# . YYYY <label> +# Released on: YYYY-MM-DD +# middot is U+00B7. we also accept " - " and " / " because some +# uploaders use those. +sub parseYTMusicDescription { + my ($desc) = @_; + return {} unless defined $desc && length $desc; + return {} unless $desc =~ /Provided to YouTube by/i; + + my @lines = grep { /\S/ } split /\n/, $desc; + shift @lines if @lines && $lines[0] =~ /Provided to YouTube by/i; + + return {} unless @lines >= 2; + + my $titleLine = $lines[0]; + my $album = $lines[1]; + + my @parts = split /\s*(?:\x{00b7}|\xc2\xb7|·| - | \/ )\s*/, $titleLine; + return {} unless @parts >= 2; + + my $title = shift @parts; + my $artist = join(', ', @parts); + + s/^\s+|\s+$//g for ($title, $artist, $album); + + my $year; + for my $ln (@lines) { + if ($ln =~ /Released on:\s*(\d{4})/) { + $year = $1; + last; + } + if ($ln =~ /^\s*(?:\x{00b7}|\xc2\xb7|·|\xe2\x84\x97|\(P\))\s*(\d{4})\b/) { + $year ||= $1; + } + } + + return { + title => $title, + artist => $artist, + album => $album, + year => $year, + }; +} + +# topic categories are wikipedia URLs like .../wiki/Pop_music. +# trim to "Pop music". drop the generic "Music" entry, useless tag. +sub topicCategoriesToGenre { + my ($topicDetails) = @_; + return undef unless ref($topicDetails) eq 'HASH'; + my $cats = $topicDetails->{topicCategories}; + return undef unless ref($cats) eq 'ARRAY' && @$cats; + + my @names; + for my $url (@$cats) { + next unless $url =~ m{/wiki/([^/]+)$}; + my $name = $1; + $name =~ s/_/ /g; + $name =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge; + next if $name eq 'Music'; + push @names, $name; + } + + return undef unless @names; + return join(', ', @names); +} + +# ISO 8601 2024-08-23T14:00:00Z to "2024" +sub yearFromPublishedAt { + my ($publishedAt) = @_; + return undef unless defined $publishedAt; + return $1 if $publishedAt =~ /^(\d{4})/; + return undef; +} + +# v3 item to flat metadata hash. each field is best-effort. +sub buildFromV3Item { + my ($item) = @_; + return {} unless ref($item) eq 'HASH'; + + my $snippet = $item->{snippet} || {}; + my $content = $item->{contentDetails} || {}; + my $topics = $item->{topicDetails} || {}; + + my ($artist, $title, $fulltitle) = parseDashTitle($snippet->{title}); + + # description block beats the dash split if its there + my $ytm = parseYTMusicDescription($snippet->{description}); + my $album; + my $year; + + if ($ytm->{title}) { + $title = $ytm->{title}; + $fulltitle = $snippet->{title}; + $artist = $ytm->{artist} if $ytm->{artist}; + $album = $ytm->{album}; + $year = $ytm->{year}; + } + + $artist ||= $snippet->{channelTitle}; + $year ||= yearFromPublishedAt($snippet->{publishedAt}); + + my $genre = topicCategoriesToGenre($topics); + $genre ||= 'Music' if ($snippet->{categoryId} || 0) == CATEGORY_MUSIC; + + my $duration = $content->{duration}; + my ($misc, $hour, $min, $sec) = $duration =~ /P(?:([^T]*))T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/ if $duration; + $duration = ($sec || 0) + (($min || 0) * 60) + (($hour || 0) * 3600); + + return { + title => $title || '', + artist => $artist || '', + album => $album, + year => $year, + genre => $genre, + duration => $duration || 0, + _fulltitle => $fulltitle, + _snippet => $snippet, + }; +} + +1; From d37bedf5c76828d550c58f7bdfb95da28f16d0be Mon Sep 17 00:00:00 2001 From: Alexander Adam <alexander.adam@vade.io> Date: Tue, 19 May 2026 19:23:42 +0200 Subject: [PATCH 13/21] use Metadata.pm in _getBulkMetadata --- plugin/ProtocolHandler.pm | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/plugin/ProtocolHandler.pm b/plugin/ProtocolHandler.pm index 356a65c..99eac58 100644 --- a/plugin/ProtocolHandler.pm +++ b/plugin/ProtocolHandler.pm @@ -50,6 +50,7 @@ use Plugins::YouTube::M4a; use Plugins::YouTube::MPEGTS; use Plugins::YouTube::Utils; use Plugins::YouTube::HTTP; +use Plugins::YouTube::Metadata; use constant MIN_OUT => 8192; use constant DATA_CHUNK => 128*1024; @@ -1218,30 +1219,22 @@ sub _getBulkMetadata { foreach my $item (@{$result->{items}}) { my $snippet = $item->{snippet}; - my $title = $snippet->{'title'}; my $cover = my $icon = Plugins::YouTube::Plugin::_getImage($snippet->{thumbnails}); - my $artist = ""; - my $fulltitle; + my $built = Plugins::YouTube::Metadata::buildFromV3Item($item); - if ($title =~ /(.*) - (.*)/) { - $fulltitle = $title; - $artist = $1; - $title = $2; - } - - my $duration = $item->{contentDetails}->{duration}; - main::DEBUGLOG && $log->is_debug && $log->debug("Duration: $duration"); - my ($misc, $hour, $min, $sec) = $duration =~ /P(?:([^T]*))T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/; - $duration = ($sec || 0) + (($min || 0) * 60) + (($hour || 0) * 3600); + main::DEBUGLOG && $log->is_debug && $log->debug("Duration: $item->{contentDetails}->{duration}"); my $meta = { - title => $title || '', - artist => $artist, - duration => $duration || 0, - icon => $icon, - cover => $cover || $icon, - type => 'YouTube', - _fulltitle => $fulltitle, + title => $built->{title}, + artist => $built->{artist}, + album => $built->{album}, + year => $built->{year}, + genre => $built->{genre}, + duration => $built->{duration}, + icon => $icon, + cover => $cover || $icon, + type => 'YouTube', + _fulltitle => $built->{_fulltitle}, _thumbnails => $snippet->{thumbnails}, }; From 980ff2787cf8ead1d3106341235e44c898a56c2f Mon Sep 17 00:00:00 2001 From: Alexander Adam <alexander.adam@vade.io> Date: Tue, 19 May 2026 20:08:11 +0200 Subject: [PATCH 14/21] add YTMusic::Parser --- plugin/YTMusic/Parser.pm | 163 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 plugin/YTMusic/Parser.pm diff --git a/plugin/YTMusic/Parser.pm b/plugin/YTMusic/Parser.pm new file mode 100644 index 0000000..9e96e1c --- /dev/null +++ b/plugin/YTMusic/Parser.pm @@ -0,0 +1,163 @@ +package Plugins::YouTube::YTMusic::Parser; + +# JSON walker for the youtubei/v1/next + browse responses. + +use strict; +use warnings; + +# navigate a path. each step is a hash key or array index. +sub _nav { + my ($data, @path) = @_; + for my $step (@path) { + return undef unless defined $data; + if (ref($data) eq 'HASH') { + return undef unless $step =~ /^[a-zA-Z_]/; + $data = $data->{$step}; + } elsif (ref($data) eq 'ARRAY') { + return undef unless $step =~ /^-?\d+$/; + $data = $data->[$step]; + } else { + return undef; + } + } + return $data; +} + +# largest square cover, fallback to the last thumbnail +sub _bestCover { + my ($thumbs) = @_; + return undef unless ref($thumbs) eq 'ARRAY' && @$thumbs; + my $square; + for my $t (@$thumbs) { + next unless $t->{url}; + $square = $t->{url} if ($t->{width} || 0) == ($t->{height} || 0); + } + return $square || $thumbs->[-1]->{url}; +} + +# longBylineText runs: artist runs have UC browseIds, album runs MPRE, +# year is a 4-digit string. +sub _parseRuns { + my ($runs) = @_; + return {} unless ref($runs) eq 'ARRAY'; + + my (@artists, $album, $year); + for my $r (@$runs) { + my $text = $r->{text}; + next unless defined $text; + # separators + next if $text =~ /^\s*[\x{2022}\x{00b7}\x{2027}\xc2\xb7\xe2\x80\xa2]\s*$/ || $text =~ /^\s*\.\s*$/ || $text eq ' \x{2022} '; + next if $text =~ /^\s*(?:\xe2\x80\xa2|\xc2\xb7)\s*$/; + next if $text =~ /^\s+$/; + + my $browseId = _nav($r, 'navigationEndpoint', 'browseEndpoint', 'browseId'); + if ($browseId) { + if ($browseId =~ /^MPRE/) { + $album = $text; + } elsif ($browseId =~ /^UC/) { + push @artists, $text; + } + next; + } + if ($text =~ /^\d{4}$/) { + $year = $text; + next; + } + # duration runs (3:42), ignore + next if $text =~ /^\d+:\d+(?::\d+)?$/; + # artist without id + push @artists, $text if length($text) > 1; + } + + return { + artist => @artists ? join(', ', @artists) : undef, + album => $album, + year => $year, + }; +} + +# find the playlistPanelVideoRenderer for $videoId. usually first entry. +sub _findPanelVideo { + my ($resp, $videoId) = @_; + my $contents = _nav($resp, + 'contents', 'singleColumnMusicWatchNextResultsRenderer', + 'tabbedRenderer', 'watchNextTabbedResultsRenderer', + 'tabs', 0, 'tabRenderer', 'content', + 'musicQueueRenderer', 'content', 'playlistPanelRenderer', 'contents', + ); + return undef unless ref($contents) eq 'ARRAY'; + + for my $entry (@$contents) { + my $r = $entry->{playlistPanelVideoRenderer} + || _nav($entry, 'playlistPanelVideoWrapperRenderer', 'primaryRenderer', 'playlistPanelVideoRenderer'); + next unless $r; + return $r if ($r->{videoId} || '') eq $videoId; + } + # id didnt match (live mix etc), take the first + for my $entry (@$contents) { + my $r = $entry->{playlistPanelVideoRenderer} + || _nav($entry, 'playlistPanelVideoWrapperRenderer', 'primaryRenderer', 'playlistPanelVideoRenderer'); + return $r if $r; + } + return undef; +} + +sub _findLyricsBrowseId { + my ($resp) = @_; + my $tabs = _nav($resp, + 'contents', 'singleColumnMusicWatchNextResultsRenderer', + 'tabbedRenderer', 'watchNextTabbedResultsRenderer', + 'tabs', + ); + return undef unless ref($tabs) eq 'ARRAY'; + for my $tab (@$tabs) { + my $bid = _nav($tab, 'tabRenderer', 'endpoint', 'browseEndpoint', 'browseId'); + return $bid if $bid && $bid =~ /^MPLYt/; + } + return undef; +} + +sub parseWatchResponse { + my ($resp, $videoId) = @_; + return {} unless ref($resp) eq 'HASH' && $videoId; + + my $panel = _findPanelVideo($resp, $videoId); + return {} unless $panel; + + my $title = _nav($panel, 'title', 'runs', 0, 'text'); + my $runs = _nav($panel, 'longBylineText', 'runs'); + my $info = _parseRuns($runs); + my $cover = _bestCover( _nav($panel, 'thumbnail', 'thumbnails') ); + + my $videoType = _nav($panel, + 'navigationEndpoint', 'watchEndpoint', + 'watchEndpointMusicSupportedConfigs', + 'watchEndpointMusicConfig', 'musicVideoType', + ); + + my $lyricsBrowseId = _findLyricsBrowseId($resp); + + my %out = ( + title => $title, + artist => $info->{artist}, + album => $info->{album}, + year => $info->{year}, + cover => $cover, + videoType => $videoType, + lyricsBrowseId => $lyricsBrowseId, + ); + delete $out{$_} for grep { !defined $out{$_} } keys %out; + return \%out; +} + +sub parseLyricsResponse { + my ($resp) = @_; + return undef unless ref($resp) eq 'HASH'; + my $text = _nav($resp, + 'contents', 'sectionListRenderer', 'contents', 0, + 'musicDescriptionShelfRenderer', 'description', 'runs', 0, 'text', + ); + return $text; +} + +1; From e2a7f6b686e03f6e266b353023a9753decadf3e9 Mon Sep 17 00:00:00 2001 From: Alexander Adam <alexander.adam@vade.io> Date: Tue, 19 May 2026 20:39:54 +0200 Subject: [PATCH 15/21] add YTMusic.pm --- plugin/YTMusic.pm | 144 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 plugin/YTMusic.pm diff --git a/plugin/YTMusic.pm b/plugin/YTMusic.pm new file mode 100644 index 0000000..2664570 --- /dev/null +++ b/plugin/YTMusic.pm @@ -0,0 +1,144 @@ +package Plugins::YouTube::YTMusic; + +# async client for music.youtube.com/youtubei/v1. unofficial endpoint, +# stable for years. gets us artist/album/year/cover/lyrics that the +# public v3 Data API doesnt return. + +use strict; +use warnings; + +use JSON::XS::VersionOneAndTwo; +use Slim::Networking::SimpleAsyncHTTP; +use Slim::Utils::Cache; +use Slim::Utils::Log; +use Digest::MD5 qw(md5_hex); + +use Plugins::YouTube::YTMusic::Parser; + +my $log = logger('plugin.youtube'); +my $cache = Slim::Utils::Cache->new(); + +use constant ENDPOINT_BASE => 'https://music.youtube.com/youtubei/v1/'; +# innertube key, hardcoded in every music.youtube.com page +use constant INNERTUBE_KEY => 'AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30'; +use constant CACHE_TTL => 7 * 86400; +use constant FAIL_TTL => 600; + +sub _context { + my @t = gmtime; + my $cv = sprintf('1.%04d%02d%02d.01.00', $t[5]+1900, $t[4]+1, $t[3]); + return { + client => { clientName => 'WEB_REMIX', clientVersion => $cv, hl => 'en', gl => 'US' }, + user => {}, + }; +} + +sub _post { + my ($endpoint, $body, $cb) = @_; + + my $url = ENDPOINT_BASE . $endpoint . '?key=' . INNERTUBE_KEY . '&prettyPrint=false'; + $body->{context} = _context(); + + my $json = eval { to_json($body) }; + if ($@) { + $log->error("ytm: failed to encode body: $@"); + $cb->(undef); + return; + } + + Slim::Networking::SimpleAsyncHTTP->new( + sub { + my $response = shift; + my $result = eval { from_json($response->content) }; + if ($@) { + $log->error("ytm: bad json: $@"); + $cb->(undef); + return; + } + $cb->($result); + }, + sub { + $log->warn("ytm: $endpoint failed: " . ($_[1] || 'unknown')); + $cb->(undef); + }, + { timeout => 15 }, + )->post( + $url, + 'Content-Type' => 'application/json', + 'Origin' => 'https://music.youtube.com', + 'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 LMS-YouTube', + $json, + ); +} + +# returns { artist, album, year, cover, lyricsBrowseId, videoType } or undef +sub getMusicMetadata { + my ($class, $videoId, $cb) = @_; + + my $failKey = "ytm:fail-$videoId"; + if ($cache->get($failKey)) { + $cb->(undef); + return; + } + + my $cacheKey = "ytm:meta-$videoId"; + if (my $cached = $cache->get($cacheKey)) { + $cb->($cached); + return; + } + + main::INFOLOG && $log->is_info && $log->info("ytm: fetch $videoId"); + + _post('next', { + videoId => $videoId, + playlistId => 'RDAMVM' . $videoId, + enablePersistentPlaylistPanel => JSON::XS::true, + isAudioOnly => JSON::XS::true, + }, sub { + my $resp = shift; + unless ($resp) { + $cache->set($failKey, 1, FAIL_TTL); + $cb->(undef); + return; + } + + my $parsed = Plugins::YouTube::YTMusic::Parser::parseWatchResponse($resp, $videoId); + unless ($parsed && %$parsed) { + $cache->set($failKey, 1, FAIL_TTL); + $cb->(undef); + return; + } + + $cache->set($cacheKey, $parsed, CACHE_TTL); + $cb->($parsed); + }); +} + +# plain text lyrics for an MPLYt... browseId +sub getLyrics { + my ($class, $browseId, $cb) = @_; + + return $cb->(undef) unless $browseId && $browseId =~ /^MPLYt/; + + my $cacheKey = "ytm:lyrics-$browseId"; + if (my $cached = $cache->get($cacheKey)) { + $cb->($cached); + return; + } + + _post('browse', { + browseId => $browseId, + }, sub { + my $resp = shift; + unless ($resp) { + $cb->(undef); + return; + } + + my $text = Plugins::YouTube::YTMusic::Parser::parseLyricsResponse($resp); + $cache->set($cacheKey, $text, CACHE_TTL) if defined $text; + $cb->($text); + }); +} + +1; From 7ae4288bbc83a86ce5074dab429f637b30f04c22 Mon Sep 17 00:00:00 2001 From: Alexander Adam <alexander.adam@vade.io> Date: Tue, 19 May 2026 21:14:36 +0200 Subject: [PATCH 16/21] _getBulkMetadata: enrich music tracks via YTM --- plugin/ProtocolHandler.pm | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/plugin/ProtocolHandler.pm b/plugin/ProtocolHandler.pm index 99eac58..ca067c7 100644 --- a/plugin/ProtocolHandler.pm +++ b/plugin/ProtocolHandler.pm @@ -51,6 +51,7 @@ use Plugins::YouTube::MPEGTS; use Plugins::YouTube::Utils; use Plugins::YouTube::HTTP; use Plugins::YouTube::Metadata; +use Plugins::YouTube::YTMusic; use constant MIN_OUT => 8192; use constant DATA_CHUNK => 128*1024; @@ -1217,6 +1218,7 @@ sub _getBulkMetadata { return; } + my @musicIds; foreach my $item (@{$result->{items}}) { my $snippet = $item->{snippet}; my $cover = my $icon = Plugins::YouTube::Plugin::_getImage($snippet->{thumbnails}); @@ -1239,8 +1241,13 @@ sub _getBulkMetadata { }; $cache->set("yt:meta-" . $item->{id}, $meta, 86400); + + # only music category gets YTM enrichment + push @musicIds, $item->{id} if ($snippet->{categoryId} || 0) == 10; } + _enrichWithYTMusic($client, \@musicIds) if @musicIds && $prefs->get('rich_metadata'); + # ids we asked for but didnt get back are usually deleted or private. # cache as failed for a day so the UI poll stops asking. if ($requestedIds) { @@ -1255,6 +1262,46 @@ sub _getBulkMetadata { }, $ids); } +# refine yt:meta-$id with YTM data after the v3 baseline lands +sub _enrichWithYTMusic { + my ($client, $ids) = @_; + return unless ref($ids) eq 'ARRAY' && @$ids; + + for my $id (@$ids) { + next if $cache->get("ytm:enriched-$id"); + + Plugins::YouTube::YTMusic->getMusicMetadata($id, sub { + my $ytm = shift; + # even on no data, dont retry for an hour + $cache->set("ytm:enriched-$id", 1, 3600); + return unless $ytm && %$ytm; + + my $meta = $cache->get("yt:meta-$id") || {}; + $meta->{title} = $ytm->{title} if $ytm->{title}; + $meta->{artist} = $ytm->{artist} if $ytm->{artist}; + $meta->{album} = $ytm->{album} if $ytm->{album}; + $meta->{year} = $ytm->{year} if $ytm->{year}; + if ($ytm->{cover}) { + $meta->{cover} = $ytm->{cover}; + $meta->{icon} = $ytm->{cover}; + } + $meta->{_ytmLyricsBrowseId} = $ytm->{lyricsBrowseId} if $ytm->{lyricsBrowseId}; + $meta->{_ytmVideoType} = $ytm->{videoType} if $ytm->{videoType}; + + if (($ytm->{videoType} || '') =~ /^MUSIC_VIDEO_TYPE_/) { + $meta->{type} = 'YouTube Music'; + } + + $cache->set("yt:meta-$id", $meta, 86400); + + if ($client) { + $client->currentPlaylistUpdateTime( Time::HiRes::time() ); + Slim::Control::Request::notifyFromArray( $client, [ 'newmetadata' ] ); + } + }); + } +} + sub getIcon { my ( $class, $url ) = @_; From e9408e1566a8bba03cbd8f5b7b27621163811e7b Mon Sep 17 00:00:00 2001 From: Alexander Adam <alexander.adam@vade.io> Date: Tue, 19 May 2026 21:41:18 +0200 Subject: [PATCH 17/21] settings: rich_metadata pref --- plugin/HTML/EN/plugins/YouTube/settings/basic.html | 4 ++++ plugin/Plugin.pm | 1 + plugin/Settings.pm | 2 +- plugin/strings.txt | 8 ++++++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/plugin/HTML/EN/plugins/YouTube/settings/basic.html b/plugin/HTML/EN/plugins/YouTube/settings/basic.html index 3dc6615..15c0380 100644 --- a/plugin/HTML/EN/plugins/YouTube/settings/basic.html +++ b/plugin/HTML/EN/plugins/YouTube/settings/basic.html @@ -163,6 +163,10 @@ <input name="pref_music_playlists_only" type="checkbox" [% IF prefs.pref_music_playlists_only %] checked [% END %]> [% END %] + [% WRAPPER setting title="PLUGIN_YOUTUBE_RICHMETA" desc="PLUGIN_YOUTUBE_RICHMETA_DESC" %] + <input name="pref_rich_metadata" type="checkbox" [% IF prefs.pref_rich_metadata %] checked [% END %]> + [% END %] + [% WRAPPER setting title="PLUGIN_YOUTUBE_MAXITEMS" desc="PLUGIN_YOUTUBE_MAXITEMS_DESC" %] <input type="text" class="stdedit" name="pref_max_items" id="pref_max_items" value="[% prefs.pref_max_items %]" size="5"> [% END %] diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 3d3c6cd..1cae86b 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -64,6 +64,7 @@ $prefs->init({ auto_update_ytdlp => 0, auto_update_check_hour => 3, music_playlists_only => 1, + rich_metadata => 1, }); tie my %recentlyPlayed, 'Tie::Cache::LRU', 50; diff --git a/plugin/Settings.pm b/plugin/Settings.pm index 591899d..f7a65ee 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 music_playlists_only); +my @bool = qw(live_edge aac vorbis opus use_video highres_icons auto_update_ytdlp music_playlists_only rich_metadata); sub name { return 'PLUGIN_YOUTUBE'; diff --git a/plugin/strings.txt b/plugin/strings.txt index 0277b30..a0d3474 100644 --- a/plugin/strings.txt +++ b/plugin/strings.txt @@ -367,6 +367,14 @@ PLUGIN_YOUTUBE_MUSICONLY_DESC 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_RICHMETA + DE Reichhaltige Metadaten von YouTube Music + EN Rich metadata from YouTube Music + +PLUGIN_YOUTUBE_RICHMETA_DESC + DE Holt Künstler, Album, Jahr und ein quadratisches Cover für Musiktitel über den (inoffiziellen) YouTube-Music-Endpunkt. Wenn der Endpunkt einmal kaputt geht, hier ausschalten. + EN Fetch artist, album, year and a square album cover for music tracks from the (unofficial) YouTube Music endpoint. Turn this off if the endpoint ever breaks. + PLUGIN_YOUTUBE_ICONRES CS Ikony s vysokým rozlišením DA Højopløste ikoner From d25dbfe65f3b7ca0c215fb4a3da47a0cc87be50c Mon Sep 17 00:00:00 2001 From: Alexander Adam <alexander.adam@vade.io> Date: Tue, 19 May 2026 22:09:34 +0200 Subject: [PATCH 18/21] type string: split ATV/OMV/UGC --- plugin/ProtocolHandler.pm | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugin/ProtocolHandler.pm b/plugin/ProtocolHandler.pm index ca067c7..ba3ffec 100644 --- a/plugin/ProtocolHandler.pm +++ b/plugin/ProtocolHandler.pm @@ -1288,9 +1288,14 @@ sub _enrichWithYTMusic { $meta->{_ytmLyricsBrowseId} = $ytm->{lyricsBrowseId} if $ytm->{lyricsBrowseId}; $meta->{_ytmVideoType} = $ytm->{videoType} if $ytm->{videoType}; - if (($ytm->{videoType} || '') =~ /^MUSIC_VIDEO_TYPE_/) { - $meta->{type} = 'YouTube Music'; - } + # ATV = audio track, OMV = official video, UGC = user upload + my $vt = $ytm->{videoType} || ''; + if ($vt eq 'MUSIC_VIDEO_TYPE_ATV') { $meta->{type} = 'YouTube Music (Track)' } + elsif ($vt eq 'MUSIC_VIDEO_TYPE_OMV' + || $vt eq 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC') { + $meta->{type} = 'YouTube Music (Video)' } + elsif ($vt eq 'MUSIC_VIDEO_TYPE_UGC') { $meta->{type} = 'YouTube Music (User)' } + elsif ($vt =~ /^MUSIC_VIDEO_TYPE_/) { $meta->{type} = 'YouTube Music' } $cache->set("yt:meta-$id", $meta, 86400); From 639bd1413ce61df5289a68d68e797ed8dde93d37 Mon Sep 17 00:00:00 2001 From: Alexander Adam <alexander.adam@vade.io> Date: Tue, 19 May 2026 22:51:07 +0200 Subject: [PATCH 19/21] lyrics: add track info entry --- plugin/Plugin.pm | 44 ++++++++++++++++++++++++++++++++++++++++++++ plugin/strings.txt | 8 ++++++++ 2 files changed, 52 insertions(+) diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 1cae86b..4a24fb0 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -23,6 +23,7 @@ use Plugins::YouTube::API; use Plugins::YouTube::ProtocolHandler; use Plugins::YouTube::ListProtocolHandler; use Plugins::YouTube::Oauth2; +use Plugins::YouTube::YTMusic; use constant BASE_URL => 'www.youtube.com/v/'; use constant STREAM_BASE_URL => 'youtube://' . BASE_URL; @@ -91,6 +92,11 @@ sub initPlugin { func => \&trackInfoMenu, ) ); + Slim::Menu::TrackInfo->registerInfoProvider( youtubelyrics => ( + after => 'youtube', + func => \&lyricsInfoMenu, + ) ); + Slim::Menu::TrackInfo->registerInfoProvider( youtubevideo => ( after => 'bottom', func => \&webVideoLink, @@ -794,6 +800,44 @@ sub trackInfoMenu { return; } +sub lyricsInfoMenu { + my ($client, $url, $track, $remoteMeta) = @_; + + return unless $client && $url && $url =~ /^youtube:/; + + my $id = Plugins::YouTube::ProtocolHandler->getId($url); + return unless $id; + + my $cache = Slim::Utils::Cache->new(); + my $meta = $cache->get("yt:meta-$id"); + my $bid = $meta && $meta->{_ytmLyricsBrowseId}; + return unless $bid; + + return { + type => 'link', + name => cstring($client, 'PLUGIN_YOUTUBE_LYRICS'), + url => \&_lyricsHandler, + passthrough => [ { browseId => $bid } ], + }; +} + +sub _lyricsHandler { + my ($client, $cb, $args, $params) = @_; + Plugins::YouTube::YTMusic->getLyrics($params->{browseId}, sub { + my $text = shift; + if (!$text) { + $cb->({ items => [{ + name => cstring($client, 'PLUGIN_YOUTUBE_LYRICS_NONE'), + type => 'text', + }]}); + return; + } + # one menu line per text line, easier to scroll + my @lines = split /\r?\n/, $text; + $cb->({ items => [ map { { name => $_, type => 'text' } } @lines ] }); + }); +} + sub albumInfoMenu { my ($client, $url, $album, $remoteMeta) = @_; diff --git a/plugin/strings.txt b/plugin/strings.txt index a0d3474..0cff6bc 100644 --- a/plugin/strings.txt +++ b/plugin/strings.txt @@ -209,6 +209,14 @@ PLUGIN_YOUTUBE_ON_YOUTUBE DE Auf YouTube EN On YouTube +PLUGIN_YOUTUBE_LYRICS + DE Liedtext + EN Lyrics + +PLUGIN_YOUTUBE_LYRICS_NONE + DE Kein Liedtext verfügbar. + EN No lyrics available. + PLUGIN_YOUTUBE_WEBLINK CS Sledovat video na YouTube DA Vis YouTube video From ff5cdde5f1a46bc5233e317ec347a10aeba30407 Mon Sep 17 00:00:00 2001 From: Alexander Adam <alexander.adam@vade.io> Date: Tue, 19 May 2026 23:31:42 +0200 Subject: [PATCH 20/21] ytm: cap 4 in flight, dedupe ids --- plugin/YTMusic.pm | 60 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/plugin/YTMusic.pm b/plugin/YTMusic.pm index 2664570..8ecaf58 100644 --- a/plugin/YTMusic.pm +++ b/plugin/YTMusic.pm @@ -23,6 +23,19 @@ use constant ENDPOINT_BASE => 'https://music.youtube.com/youtubei/v1/'; use constant INNERTUBE_KEY => 'AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30'; use constant CACHE_TTL => 7 * 86400; use constant FAIL_TTL => 600; +# cap so we dont starve the shared async http pool, playback needs it too +use constant MAX_PARALLEL => 4; + +my %inFlight; +my @queue; +my $running = 0; + +sub _kickQueue { + while ($running < MAX_PARALLEL && @queue) { + my $next = shift @queue; + _run(@$next); + } +} sub _context { my @t = gmtime; @@ -87,7 +100,27 @@ sub getMusicMetadata { return; } - main::INFOLOG && $log->is_info && $log->info("ytm: fetch $videoId"); + # in flight already, next bulk poll will pick it up + if ($inFlight{$videoId}) { + $cb->(undef); + return; + } + + if ($running >= MAX_PARALLEL) { + push @queue, [ $videoId, $cb ]; + return; + } + _run($videoId, $cb); +} + +sub _run { + my ($videoId, $cb) = @_; + my $failKey = "ytm:fail-$videoId"; + my $cacheKey = "ytm:meta-$videoId"; + + $inFlight{$videoId} = 1; + $running++; + main::INFOLOG && $log->is_info && $log->info("ytm: fetch $videoId ($running in flight, " . scalar(@queue) . " queued)"); _post('next', { videoId => $videoId, @@ -96,21 +129,24 @@ sub getMusicMetadata { isAudioOnly => JSON::XS::true, }, sub { my $resp = shift; - unless ($resp) { - $cache->set($failKey, 1, FAIL_TTL); - $cb->(undef); - return; - } + delete $inFlight{$videoId}; + $running--; - my $parsed = Plugins::YouTube::YTMusic::Parser::parseWatchResponse($resp, $videoId); - unless ($parsed && %$parsed) { + my $result; + if (!$resp) { $cache->set($failKey, 1, FAIL_TTL); - $cb->(undef); - return; + } else { + my $parsed = Plugins::YouTube::YTMusic::Parser::parseWatchResponse($resp, $videoId); + if ($parsed && %$parsed) { + $cache->set($cacheKey, $parsed, CACHE_TTL); + $result = $parsed; + } else { + $cache->set($failKey, 1, FAIL_TTL); + } } - $cache->set($cacheKey, $parsed, CACHE_TTL); - $cb->($parsed); + $cb->($result); + _kickQueue(); }); } From 2eaf0ebcee0a1d6e9eadc876794a3b52c33cfc3d Mon Sep 17 00:00:00 2001 From: Alexander Adam <alexander.adam@vade.io> Date: Tue, 19 May 2026 23:42:51 +0200 Subject: [PATCH 21/21] playlistHandler: use cached oauth token for private playlists --- plugin/Plugin.pm | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 4a24fb0..2ba5fe3 100644 --- a/plugin/Plugin.pm +++ b/plugin/Plugin.pm @@ -545,6 +545,12 @@ sub playlistHandler { # if it slips in here YT returns the wrong playlist. delete $params->{mine}; + # private playlists 404 without the oauth token + if (!$params->{access_token} && (my $token = $cache->get('yt:access_token'))) { + $params->{access_token} = $token; + $params->{_noKey} = 1; + } + Plugins::YouTube::API->searchDirect('playlistItems', sub { $cb->( _renderList($_[0], $prefs->get('playlist_sort')) ); }, $params);