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
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 @@ -151,6 +151,14 @@
</script>
[% END %]

[% WRAPPER setting title="PLUGIN_YOUTUBE_YTDLP_EXTRA_ARGS" desc="PLUGIN_YOUTUBE_YTDLP_EXTRA_ARGS_DESC" %]
<input type="text" class="stdedit" name="pref_yt_dlp_extra_args" id="pref_yt_dlp_extra_args" value="[% prefs.pref_yt_dlp_extra_args %]" size="60">
[% END %]

[% WRAPPER setting title="PLUGIN_YOUTUBE_YTDLP_COOKIES" desc="PLUGIN_YOUTUBE_YTDLP_COOKIES_DESC" %]
<textarea class="stdedit" name="pref_yt_dlp_cookies" id="pref_yt_dlp_cookies" rows="8" cols="80" spellcheck="false" autocomplete="off" wrap="off" placeholder="# Netscape HTTP Cookie File">[% prefs.pref_yt_dlp_cookies | html %]</textarea>
[% END %]

[% WRAPPER setting title="PLUGIN_YOUTUBE_APIKEY" desc="PLUGIN_YOUTUBE_APIKEY_DESC" %]
<input type="text" class="stdedit" name="pref_APIkey" id="pref_APIkey" value="[% prefs.pref_APIkey %]" size="45">
[% END %]
Expand Down
76 changes: 72 additions & 4 deletions plugin/ProtocolHandler.pm
Original file line number Diff line number Diff line change
Expand Up @@ -602,13 +602,70 @@ sub getId {
return undef;
}

sub _classifyYtDlpErr {
my ($err) = @_;
return ('age_restricted', 'PLUGIN_YOUTUBE_VIDEO_AGE_RESTRICTED')
if $err =~ /age[- ]restricted|confirm your age/i;
return ('private', 'PLUGIN_YOUTUBE_VIDEO_PRIVATE')
if $err =~ /Private video|Sign in to confirm/i;
return ('unavailable', 'PLUGIN_YOUTUBE_VIDEO_UNAVAILABLE')
if $err =~ /Video unavailable|This video is not available|Video not available/i;
return ('unknown', undef);
}

sub _ytDlpExtraArgs {
my @args;

# if the user pasted cookies into the textarea pref, materialise
# them and add --cookies. happens before the free form extra args
# so the user can still override with their own --cookies if they
# want a different path.
my $cookies = $prefs->get('yt_dlp_cookies');
if (defined $cookies && $cookies =~ /\S/) {
my $path = Plugins::YouTube::Utils::yt_dlp_cookies_file($cookies);
push @args, '--cookies', $path if $path;
}

my $raw = $prefs->get('yt_dlp_extra_args');
if (defined $raw && $raw =~ /\S/) {
# whitespace split. simple. people who need quoted args can edit
# the pref to use them but the common case (--js-runtimes foo:bar)
# does not need quoting.
$raw =~ s/^\s+|\s+$//g;
push @args, split /\s+/, $raw;
}

return @args;
}

sub _reportYtDlpFailure {
my ($song, $id, $err) = @_;
my ($kind, $stringKey) = _classifyYtDlpErr($err || '');
my $meta = $cache->get("yt:meta-$id") || {};
my $title = $meta->{_fulltitle} || $meta->{title} || $song->track->title || $id;
my $oneline = ($err // '') =~ s/\s+/ /gr;

if ($kind ne 'unknown') {
$log->warn("yt-dlp $kind for $id ($title): $oneline");
} else {
$log->error("yt-dlp failed for $id ($title): $oneline");
}

if ($stringKey && eval { $song->master && $song->master->can('showBriefly') }) {
my $msg = string($stringKey);
$song->master->showBriefly({ line => [ $title, $msg ] }, { duration => 3, block => 0 });
}
}

sub getNextTrack {
my ($class, $song, $successCb, $errorCb,) = @_;

my $yt_dlp = Plugins::YouTube::Utils::yt_dlp_bin($prefs->get('yt_dlp'));
$log->info("Using yt_dlp $yt_dlp");
return $_[3]->("cannot find yt-dlp") unless $yt_dlp;

my @extra = _ytDlpExtraArgs();

my $masterUrl = $song->track()->url;

$song->pluginData(lastpos => ($masterUrl =~ /&lastpos=([\d]+)/)[0] || 0);
Expand All @@ -627,7 +684,8 @@ sub getNextTrack {
my $pid = 0;
my $lambda;
my $out = tmpnam();
my $cmd = qq{$yt_dlp -j $url >$out};
my $extra_str = @extra ? ' ' . join(' ', @extra) : '';
my $cmd = qq{$yt_dlp -j$extra_str $url >$out 2>${out}.err};

$log->info("Get tracks with $cmd");

Expand All @@ -646,11 +704,17 @@ sub getNextTrack {
# response is on 1st line
local @ARGV = ($out);
my $tracks = <>;
my $err = '';
if (open my $fh, '<', "${out}.err") { local $/; $err = <$fh>; close $fh; unlink "${out}.err"; }

main::INFOLOG && $log->is_info && $log->info("yt-dlp finished with $exitcode in ", time() - $now, " seconds");
$tracks = eval { decode_json($tracks) };

$log->error("yt-dlp failed") && $errorCb->($@) && return if $@ || !$tracks;
if ($@ || !$tracks) {
_reportYtDlpFailure($song, $id, $err);
$errorCb->($@);
return;
}

# duration is at the top level
$song->track->secs( $tracks->{'duration'} );
Expand All @@ -662,7 +726,7 @@ sub getNextTrack {
} else {

my $cv = AnyEvent::Util::run_cmd(
[ $yt_dlp, '-j', $url],
[ $yt_dlp, '-j', @extra, $url],
"<", "/dev/null",
">" , \my $tracks,
"2>", \my $err,
Expand All @@ -672,7 +736,11 @@ sub getNextTrack {
main::INFOLOG && $log->is_info && $log->info("yt-dlp finished in ", time() - $now, " seconds");
$tracks = eval { decode_json($tracks) };

$log->error("yt-dlp failed $err") && $errorCb->($@) && return if $@ || !$tracks;
if ($@ || !$tracks) {
_reportYtDlpFailure($song, $id, $err);
$errorCb->($@);
return;
}

# duration is at the top level
$song->track->secs( $tracks->{'duration'} );
Expand Down
15 changes: 12 additions & 3 deletions plugin/Settings.pm
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ sub page {
}

sub prefs {
return (preferences('plugin.youtube'), qw(channel_prefix channel_suffix playlist_prefix
playlist_suffix country max_items APIkey client_id client_secret live_delay
cache_ttl search_rank search_sort channel_rank channel_sort playlist_sort query_size auto_update_check_hour), @bool);
return (preferences('plugin.youtube'), qw(channel_prefix channel_suffix playlist_prefix
playlist_suffix country max_items APIkey client_id client_secret live_delay
cache_ttl search_rank search_sort channel_rank channel_sort playlist_sort query_size auto_update_check_hour
yt_dlp_extra_args yt_dlp_cookies), @bool);
}

sub init {
Expand Down Expand Up @@ -75,6 +76,14 @@ if ($params->{flushcache}) {
$params->{pref_max_items} = min($params->{pref_max_items}, 500);
$params->{pref_live_delay} = max($params->{pref_live_delay}, 30);
$params->{pref_APIkey} =~ s/^\s+|\s+$//g;

# normalise pasted cookies.txt before save. browsers export with CRLF
# and stray whitespace around the edges, neither of which yt-dlp likes.
if (defined $params->{pref_yt_dlp_cookies}) {
$params->{pref_yt_dlp_cookies} =~ s/\r\n/\n/g;
$params->{pref_yt_dlp_cookies} =~ s/\A\s+//;
$params->{pref_yt_dlp_cookies} =~ s/\s+\z//;
}

$cache->remove('yt:access_token') if $params->{clear_token};

Expand Down
43 changes: 43 additions & 0 deletions plugin/Utils.pm
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use Config;
use File::Spec::Functions;

use Slim::Utils::Log;
use Slim::Utils::Prefs;

my $log = logger('plugin.youtube');

Expand Down Expand Up @@ -108,4 +109,46 @@ sub yt_dlp_binaries {
# Returns: 1 on success, 0 on failure
sub set_yt_dlp_writable { return main::ISWINDOWS || (-e $_[0] && chmod(0755, $_[0])) }
sub set_yt_dlp_readonly { return main::ISWINDOWS || (-e $_[0] && chmod(0555, $_[0])) }

# materialize the yt_dlp_cookies pref into a cookies.txt file under the
# LMS cachedir. yt-dlp wants a path, not content, so the textarea pref
# gets written to disk before each invocation. file is recreated when
# the pref content changes so we don't leave stale cookies around.
#
# returns the path if a cookies file exists and is non-empty,
# otherwise undef so the caller knows to skip --cookies entirely.
sub yt_dlp_cookies_file {
my $raw = shift;
return undef unless defined $raw && $raw =~ /\S/;

# strip leading/trailing whitespace but keep the inner newlines.
# also normalise CRLF since browser exports often have them.
$raw =~ s/\r\n/\n/g;
$raw =~ s/\A\s+//;
$raw =~ s/\s+\z//;
$raw .= "\n";

my $dir = Slim::Utils::Prefs::preferences('server')->get('cachedir')
|| File::Spec::Functions::tmpdir()
|| '/tmp';
my $path = catdir($dir, 'youtube-cookies.txt');

# only rewrite if content changed. avoids touching the file on every
# track and avoids tripping any future inotify watcher.
my $existing = '';
if (open my $fh, '<', $path) { local $/; $existing = <$fh>; close $fh; }
if ($existing ne $raw) {
if (open my $fh, '>', $path) {
print $fh $raw;
close $fh;
chmod 0600, $path;
} else {
$log->warn("could not write $path: $!");
return undef;
}
}

return $path;
}

1;
28 changes: 28 additions & 0 deletions plugin/strings.txt
Original file line number Diff line number Diff line change
Expand Up @@ -708,3 +708,31 @@ PLUGIN_YOUTUBE_LAST_AUTO_UPDATE
CS Poslední automatická aktualizace
DA Sidste automatiske opdatering
DE Letzte automatische Aktualisierung

PLUGIN_YOUTUBE_YTDLP_EXTRA_ARGS
EN Extra yt-dlp arguments
DE Zusätzliche yt-dlp-Argumente

PLUGIN_YOUTUBE_YTDLP_EXTRA_ARGS_DESC
EN Optional extra args appended to every yt-dlp call. Whitespace separated. Common case when only node is installed: "--no-js-runtimes --js-runtimes node:/usr/bin/node". The --no-js-runtimes part clears the deno default so node actually gets used. Leave empty for default behaviour.
DE Optionale zusätzliche Argumente, die an jeden yt-dlp Aufruf angehängt werden. Durch Leerzeichen getrennt. Üblicher Fall, wenn nur node installiert ist: "--no-js-runtimes --js-runtimes node:/usr/bin/node". Der Teil --no-js-runtimes löscht den deno Standard, damit node tatsächlich verwendet wird. Leer lassen für Standardverhalten.

PLUGIN_YOUTUBE_YTDLP_COOKIES
EN YouTube cookies (cookies.txt)
DE YouTube-Cookies (cookies.txt)

PLUGIN_YOUTUBE_YTDLP_COOKIES_DESC
EN Paste a Netscape format cookies.txt exported from a browser where you are logged in to YouTube. When YouTube starts asking "Sign in to confirm you're not a bot" for this server's IP, cookies are the fix. Plugin writes the content to disk and passes --cookies to yt-dlp on every call. Leave empty to disable. Treat the content as a password, anyone with it can act as you on YouTube.
DE Hier eine im Netscape-Format exportierte cookies.txt einfügen, aus einem Browser, in dem Sie bei YouTube angemeldet sind. Wenn YouTube für diese Server-IP "Bestätigen, dass Sie kein Bot sind" verlangt, sind Cookies die Lösung. Das Plugin schreibt den Inhalt auf die Festplatte und übergibt --cookies bei jedem yt-dlp Aufruf. Leer lassen zum Deaktivieren. Inhalt wie ein Passwort behandeln, jeder mit Zugriff kann in Ihrem Namen auf YouTube agieren.

PLUGIN_YOUTUBE_VIDEO_UNAVAILABLE
EN Video unavailable, skipping
DE Video nicht verfügbar, überspringe

PLUGIN_YOUTUBE_VIDEO_PRIVATE
EN Private video, skipping
DE Privates Video, überspringe

PLUGIN_YOUTUBE_VIDEO_AGE_RESTRICTED
EN Age restricted, skipping
DE Altersbeschränkung, überspringe