Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions plugin/API.pm
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ sub getVideoDetails {
}, $cb);
}

# single-shot fetch of the first N items of a playlist, no paging. used by
# the music-playlist filter where we just want a small sample to classify.
# pass extra (eg. access_token + _noKey) to read private playlists.
sub getPlaylistSample {
my ( $class, $cb, $playlistId, $max, $extra ) = @_;

my %args = (
part => 'snippet',
playlistId => $playlistId,
maxResults => $max || 5,
_noRegion => 1,
_cache_ttl => 86400,
%{ $extra || {} },
);

_call('playlistItems', \%args, $cb);
}

sub _pagedCall {
my ( $method, $args, $cb ) = @_;
my $wantedItems = $args->{_quantity} || $prefs->get('max_items');
Expand Down
4 changes: 4 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,10 @@
<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_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
147 changes: 144 additions & 3 deletions plugin/Plugin.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -374,15 +375,155 @@ sub myPlaylistHandler {
access_token => $cache->get('yt:access_token'),
};

# auth bits the sample probe needs to see private playlists
my $probeAuth = {
_noKey => 1,
access_token => $cache->get('yt:access_token'),
};

# when the filter is on we have to grab the full set up front.
# OPML level-2 calls us with quantity=1, index=N. if we only fetch
# the first page, then filter, the row at index N points at a
# different playlist than what the menu showed, or just falls off
# the end (looks empty). override index and quantity in that case.
my $filterOn = $prefs->get('music_playlists_only');
my $fetchIndex = $filterOn ? 0 : $args->{index};
my $fetchQuantity = $filterOn ? $prefs->get('max_items') : $args->{quantity};

Plugins::YouTube::API->searchDirect('playlists', sub {
$cb->( _renderList($_[0], 'title', $account) );
my $result = $_[0];
_filterMusicPlaylists($result, $probeAuth, sub {
$cb->( _renderList($_[0], 'title', $account) );
});
}, {
%$account,
_index => $args->{index},
_quantity => $args->{quantity},
_index => $fetchIndex,
_quantity => $fetchQuantity,
});
}

# keep playlists where any of the sampled items is in Music category
# (10). same idea as the YouTube Music / Android app menu. we look at
# more than one item because the first one is often an intro, ad, or
# just badly tagged. decision per playlist id goes into the cache so
# we dont re-probe on every navigation.
sub _filterMusicPlaylists {
my ($result, $probeAuth, $cb) = @_;

return $cb->($result) unless $prefs->get('music_playlists_only');
return $cb->($result) unless $result && ref $result->{items} eq 'ARRAY';

my @items = @{$result->{items}};
my @unknown;
for my $entry (@items) {
my $pid = $entry->{id} or next;
my $hit = $cache->get("yt:plmusic4-$pid");
$entry->{_musicScore} = $hit if defined $hit;
push @unknown, $entry unless defined $hit;
}

main::INFOLOG && $log->is_info && $log->info("music filter: " . scalar(@items) . " playlists, " . scalar(@unknown) . " to probe");

my $finalize = sub {
my @kept = grep { ($_->{_musicScore} || 0) > 0 } @items;
main::INFOLOG && $log->is_info && $log->info("music filter: kept " . scalar(@kept) . "/" . scalar(@items));
delete $_->{_musicScore} for @items;
$result->{items} = \@kept;
$result->{total} = scalar @kept if defined $result->{total};
$cb->($result);
};

return $finalize->() unless @unknown;

_probeMusicPlaylists(\@unknown, $probeAuth, $finalize);
}

use constant MUSIC_SAMPLE_SIZE => 5;

sub _probeMusicPlaylists {
my ($entries, $probeAuth, $done) = @_;
my $remaining = scalar @$entries;
my %vidsForPlaylist;
my %seenVid;
my @allVideoIds;

my $afterAllSamples = sub {
unless (@allVideoIds) {
# nothing playable. mark them non-music for a bit and move on
for my $e (@$entries) {
$cache->set("yt:plmusic4-" . $e->{id}, 0, 3600);
$e->{_musicScore} = 0;
}
return $done->();
}

# videos.list caps at 50 ids per call. chunk and fan out.
my @chunks;
push @chunks, [ splice(@allVideoIds, 0, 50) ] while @allVideoIds;
my $pending = scalar @chunks;
my %catFor;
my $hadError = 0;

my $finish = sub {
if ($hadError && !keys %catFor) {
# every chunk died. dont silently hide all playlists, show them.
$log->warn("music filter: video lookup failed, showing all playlists");
$_->{_musicScore} = 1 for @$entries;
return $done->();
}

for my $e (@$entries) {
my $vids = $vidsForPlaylist{$e->{id}} || [];
my $known = 0;
my $isMusic = 0;
for my $vid (@$vids) {
my $cat = $catFor{$vid};
next unless defined $cat;
$known++;
if ($cat eq '10') { $isMusic = 1 }
}
# at least one music sample = music. all known and none
# music = not music. nothing known = short ttl so we recover.
my $ttl = $known ? 86400 : 3600;
$cache->set("yt:plmusic4-" . $e->{id}, $isMusic, $ttl);
$e->{_musicScore} = $isMusic;
}

$done->();
};

for my $chunk (@chunks) {
Plugins::YouTube::API->getVideoDetails( sub {
my $vresult = shift;
if (!$vresult || $vresult->{error} || !ref $vresult->{items}) {
$log->warn("music filter: chunk lookup failed");
$hadError = 1;
} else {
for my $v (@{$vresult->{items}}) {
$catFor{$v->{id}} = $v->{snippet}->{categoryId} || '';
}
}
$finish->() if --$pending == 0;
}, join(',', @$chunk) );
}
};

for my $entry (@$entries) {
my $pid = $entry->{id};
Plugins::YouTube::API->getPlaylistSample( sub {
my $items = $_[0]->{items} || [];
my @vids;
for my $it (@$items) {
my $vid = $it->{snippet}->{resourceId}->{videoId} or next;
push @vids, $vid;
push @allVideoIds, $vid unless $seenVid{$vid}++;
}
$vidsForPlaylist{$pid} = \@vids;
$afterAllSamples->() if --$remaining == 0;
}, $pid, MUSIC_SAMPLE_SIZE, $probeAuth );
}
}

sub playlistHandler {
my ($client, $cb, $args, $params) = @_;

Expand Down
2 changes: 1 addition & 1 deletion plugin/Settings.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 12 additions & 0 deletions plugin/strings.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down