Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d22b940
fix: stop sending mine=true into playlistItems
alexanderadam May 2, 2026
a817dfc
webm: resync parser, dont kill the stream
alexanderadam May 9, 2026
18aaa1b
add music_playlists_only pref, default on
alexanderadam May 13, 2026
c2c63d8
settings: add the music-only checkbox
alexanderadam May 13, 2026
fb60df7
filter My Playlists by music category
alexanderadam May 13, 2026
28be571
back off bulk metadata when API fails
alexanderadam May 16, 2026
549aedf
music filter: sample 5 items per playlist
alexanderadam May 19, 2026
324395e
music filter: pass oauth token to sample fetch
alexanderadam May 19, 2026
d8e0943
music filter: chunk videos.list at 50 ids
alexanderadam May 19, 2026
775609e
music filter: always fetch the full set
alexanderadam May 19, 2026
56d4069
add Metadata.pm helpers
alexanderadam May 19, 2026
2864d6c
merge all fixes and music filter for testing
alexanderadam May 19, 2026
d7b367a
api: ask for topicDetails too
alexanderadam May 19, 2026
d37bedf
use Metadata.pm in _getBulkMetadata
alexanderadam May 19, 2026
980ff27
add YTMusic::Parser
alexanderadam May 19, 2026
e2a7f6b
add YTMusic.pm
alexanderadam May 19, 2026
7ae4288
_getBulkMetadata: enrich music tracks via YTM
alexanderadam May 19, 2026
e9408e1
settings: rich_metadata pref
alexanderadam May 19, 2026
d25dbfe
type string: split ATV/OMV/UGC
alexanderadam May 19, 2026
639bd14
lyrics: add track info entry
alexanderadam May 19, 2026
ff5cdde
ytm: cap 4 in flight, dedupe ids
alexanderadam May 19, 2026
2eaf0eb
playlistHandler: use cached oauth token for private playlists
alexanderadam May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion plugin/API.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 8 additions & 0 deletions plugin/HTML/EN/plugins/YouTube/settings/basic.html
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@
<input name="pref_highres_icons" type="checkbox" [% IF prefs.pref_highres_icons %] checked [% END %]>
[% END %]

[% WRAPPER setting title="PLUGIN_YOUTUBE_MUSICONLY" desc="PLUGIN_YOUTUBE_MUSICONLY_DESC" %]
<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 %]
Expand Down
153 changes: 153 additions & 0 deletions plugin/Metadata.pm
Original file line number Diff line number Diff line change
@@ -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 <distributor>
# <title> . <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;
Loading