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