diff --git a/plugin/API.pm b/plugin/API.pm index 06aa2e2..3f2e411 100644 --- a/plugin/API.pm +++ b/plugin/API.pm @@ -57,14 +57,33 @@ 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, }, $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..15c0380 100644 --- a/plugin/HTML/EN/plugins/YouTube/settings/basic.html +++ b/plugin/HTML/EN/plugins/YouTube/settings/basic.html @@ -159,6 +159,14 @@ [% END %] + [% WRAPPER setting title="PLUGIN_YOUTUBE_MUSICONLY" desc="PLUGIN_YOUTUBE_MUSICONLY_DESC" %] + + [% END %] + + [% WRAPPER setting title="PLUGIN_YOUTUBE_RICHMETA" desc="PLUGIN_YOUTUBE_RICHMETA_DESC" %] + + [% END %] + [% WRAPPER setting title="PLUGIN_YOUTUBE_MAXITEMS" desc="PLUGIN_YOUTUBE_MAXITEMS_DESC" %] [% END %] 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; diff --git a/plugin/Plugin.pm b/plugin/Plugin.pm index 43ed038..2ba5fe3 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; @@ -63,6 +64,8 @@ $prefs->init({ yt_dlp => '', auto_update_ytdlp => 0, auto_update_check_hour => 3, + music_playlists_only => 1, + rich_metadata => 1, }); tie my %recentlyPlayed, 'Tie::Cache::LRU', 50; @@ -89,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, @@ -366,23 +374,166 @@ 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, + }; + + # 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) ); + _filterMusicPlaylists($_[0], $probeAuth, sub { + $cb->( _renderList($_[0], 'title', $itemPassthrough) ); + }); }, { - %$account, - _index => $args->{index}, - _quantity => $args->{quantity}, + _cache_ttl => 60, + _noKey => 1, + mine => 'true', + access_token => $token, + _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) = @_; @@ -390,6 +541,16 @@ 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}; + + # 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); @@ -645,6 +806,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/ProtocolHandler.pm b/plugin/ProtocolHandler.pm index 15ac0e0..ba3ffec 100644 --- a/plugin/ProtocolHandler.pm +++ b/plugin/ProtocolHandler.pm @@ -50,6 +50,8 @@ use Plugins::YouTube::M4a; 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; @@ -1156,7 +1158,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 +1173,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,47 +1201,60 @@ 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; } + my @musicIds; 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}, }; $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) { + 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; @@ -1246,6 +1262,51 @@ 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}; + + # 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); + + if ($client) { + $client->currentPlaylistUpdateTime( Time::HiRes::time() ); + Slim::Control::Request::notifyFromArray( $client, [ 'newmetadata' ] ); + } + }); + } +} + sub getIcon { my ( $class, $url ) = @_; diff --git a/plugin/Settings.pm b/plugin/Settings.pm index ef46c4a..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); +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/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; } diff --git a/plugin/YTMusic.pm b/plugin/YTMusic.pm new file mode 100644 index 0000000..8ecaf58 --- /dev/null +++ b/plugin/YTMusic.pm @@ -0,0 +1,180 @@ +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; +# 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; + 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; + } + + # 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, + playlistId => 'RDAMVM' . $videoId, + enablePersistentPlaylistPanel => JSON::XS::true, + isAudioOnly => JSON::XS::true, + }, sub { + my $resp = shift; + delete $inFlight{$videoId}; + $running--; + + my $result; + if (!$resp) { + $cache->set($failKey, 1, FAIL_TTL); + } 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); + } + } + + $cb->($result); + _kickQueue(); + }); +} + +# 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; 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; diff --git a/plugin/strings.txt b/plugin/strings.txt index d70ed16..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 @@ -355,6 +363,26 @@ 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_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