diff --git a/plugin/HTML/EN/plugins/YouTube/settings/basic.html b/plugin/HTML/EN/plugins/YouTube/settings/basic.html
index 61638d1..33c18c1 100644
--- a/plugin/HTML/EN/plugins/YouTube/settings/basic.html
+++ b/plugin/HTML/EN/plugins/YouTube/settings/basic.html
@@ -151,6 +151,14 @@
[% END %]
+ [% WRAPPER setting title="PLUGIN_YOUTUBE_YTDLP_EXTRA_ARGS" desc="PLUGIN_YOUTUBE_YTDLP_EXTRA_ARGS_DESC" %]
+
+ [% END %]
+
+ [% WRAPPER setting title="PLUGIN_YOUTUBE_YTDLP_COOKIES" desc="PLUGIN_YOUTUBE_YTDLP_COOKIES_DESC" %]
+
+ [% END %]
+
[% WRAPPER setting title="PLUGIN_YOUTUBE_APIKEY" desc="PLUGIN_YOUTUBE_APIKEY_DESC" %]
[% END %]
diff --git a/plugin/ProtocolHandler.pm b/plugin/ProtocolHandler.pm
index 15ac0e0..b1699ea 100644
--- a/plugin/ProtocolHandler.pm
+++ b/plugin/ProtocolHandler.pm
@@ -602,6 +602,61 @@ 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,) = @_;
@@ -609,6 +664,8 @@ sub getNextTrack {
$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);
@@ -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");
@@ -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'} );
@@ -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,
@@ -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'} );
diff --git a/plugin/Settings.pm b/plugin/Settings.pm
index ef46c4a..45a97c4 100644
--- a/plugin/Settings.pm
+++ b/plugin/Settings.pm
@@ -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 {
@@ -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};
diff --git a/plugin/Utils.pm b/plugin/Utils.pm
index 8d76710..786ece1 100644
--- a/plugin/Utils.pm
+++ b/plugin/Utils.pm
@@ -10,6 +10,7 @@ use Config;
use File::Spec::Functions;
use Slim::Utils::Log;
+use Slim::Utils::Prefs;
my $log = logger('plugin.youtube');
@@ -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;
diff --git a/plugin/strings.txt b/plugin/strings.txt
index d70ed16..0e4d0d2 100644
--- a/plugin/strings.txt
+++ b/plugin/strings.txt
@@ -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