diff --git a/.gitignore b/.gitignore index ecf66f8..9776547 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ inc/ /MANIFEST.bak /pm_to_blib /*.zip +cookies_from_chromium.txt diff --git a/bin/gtk-pipe-viewer b/bin/gtk-pipe-viewer index 5d7a37b..1ccd3a5 100755 --- a/bin/gtk-pipe-viewer +++ b/bin/gtk-pipe-viewer @@ -61,6 +61,7 @@ use Gtk3 qw(-init); use Storable qw(); use Scalar::Util qw(looks_like_number); use List::Util qw(any max min pairs); +use Socket qw(PF_UNIX SOCK_STREAM sockaddr_un); binmode(STDOUT, ':utf8'); binmode(STDERR, ':utf8'); @@ -259,7 +260,8 @@ my %CONFIG = ( max_retries => 3, retry_delay => 1, user_agent => undef, - cookie_file => undef, + cookie_file => undef, + cookies_from_browser => undef, prefer_fork => (($^O eq 'linux') ? 0 : 1), debug => 0, fullscreen => 0, @@ -267,6 +269,7 @@ my %CONFIG = ( autoscroll_to_end => 0, single_click_play => 0, + shorts_autoplay => 1, # yt-dlp / youtube-dl support ytdl => 1, @@ -973,13 +976,20 @@ my %ResultsHistory = ( position => [], ); +# Shorts auto-play state +my $in_shorts_mode = 0; +my $shorts_autoplay_active = 0; +my $shorts_autoplay_timer = undef; +my $shorts_mpv_socket = undef; + # Locate CLI pipe-viewer $CONFIG{pipe_viewer} //= which_command('pipe-viewer') // 'pipe-viewer'; my $yv_obj = WWW::PipeViewer->new( - cache_dir => $CONFIG{cache_dir}, - env_proxy => $CONFIG{env_proxy}, - http_proxy => $CONFIG{http_proxy}, + cache_dir => $CONFIG{cache_dir}, + env_proxy => $CONFIG{env_proxy}, + http_proxy => $CONFIG{http_proxy}, + cookies_from_browser => $CONFIG{cookies_from_browser}, ); my $yv_utils = WWW::PipeViewer::Utils->new( @@ -1082,7 +1092,7 @@ sub apply_configuration { foreach my $option_name ( qw( api_host cache_dir comments_order - cookie_file debug env_proxy force_fallback http_proxy prefer_av1 + cookie_file cookies_from_browser debug env_proxy force_fallback http_proxy prefer_av1 prefer_invidious prefer_mp4 region timeout user_agent ytdl ytdl_cmd ytdlp_comments ytdlp_max_comments ytdlp_max_replies bypass_age_gate_native bypass_age_gate_with_proxy @@ -1173,6 +1183,37 @@ sub get_text { $gui->get_object('right_button_image')->set_from_pixbuf($right_arrow_pixbuf); } +# Notify on startup if cookies_from_browser is configured +if (defined($CONFIG{cookies_from_browser})) { + Glib::Idle->add( + sub { + Glib::Timeout->add( + 3000, + sub { + my $profile_loaded = eval { $yv_obj->get_profile_loaded }; + if ($profile_loaded) { + $progressbar->set_text("Profile loaded from $CONFIG{cookies_from_browser}"); + } + else { + $progressbar->set_text("No profile loaded. Use Menu > Login with Browser to log in."); + } + $progressbar->set_fraction(1.0); + toggle_progress('bar', 1); + Glib::Timeout->add( + 5000, + sub { + toggle_progress('bar', 0); + return 0; + } + ); + return 0; + } + ); + return 0; + } + ); +} + # Treeview signals { $treeview->set_activate_on_single_click($CONFIG{single_click_play}); @@ -1802,6 +1843,40 @@ HELP_TEXT # 'F11' key $accel->connect(0xffc8, ['lock-mask'], ['visible'], \&maximize_unmaximize_mainw); + # 'Escape' key — cancel shorts auto-play + $accel->connect(0xff1b, ['lock-mask'], ['visible'], sub { + if ($shorts_autoplay_active) { + cancel_shorts_autoplay(); + $progressbar->set_text("Auto-play cancelled"); + Glib::Timeout->add(2000, sub { toggle_progress('bar', 0); 0 }); + } + }); + + # 'Up' arrow — play previous Short (in Shorts mode) + $accel->connect(0xff52, ['lock-mask'], ['visible'], sub { + return unless $in_shorts_mode; + cancel_shorts_autoplay(); + play_adjacent_short(-1); + }); + + # 'Down' arrow — play next Short (in Shorts mode) + $accel->connect(0xff54, ['lock-mask'], ['visible'], sub { + return unless $in_shorts_mode; + cancel_shorts_autoplay(); + play_adjacent_short(1); + }); + + # 'Space' — toggle pause/play (in Shorts mode, also works for auto-play) + $accel->connect(0x020, ['lock-mask'], ['visible'], sub { + return unless $in_shorts_mode; + # Toggle auto-play + if ($shorts_autoplay_active) { + cancel_shorts_autoplay(); + $progressbar->set_text("Paused"); + Glib::Timeout->add(1500, sub { toggle_progress('bar', 0); 0 }); + } + }); + $mainw->add_accel_group($accel); } @@ -1915,13 +1990,25 @@ sub send_search_request { clear_search_requests(); toggle_progress($search_progress = 'pulse', 1); $worker->abort_requests($search_request); + my $is_auth_feature = ($method =~ /subscription|history|playlists/); $search_request = $worker->send_request( sub { my ($result, $request, $error) = @_; toggle_progress('pulse', 0); $search_progress = $search_request = undef; - die "$error_message\n" if $error; - die "$error_message\n" unless ($yv_utils->has_entries($result)); + if ($error || !$yv_utils->has_entries($result)) { + # If this is an auth feature and we have cookies, session may have expired + if ($is_auth_feature && ($CONFIG{cookies_from_browser} || $CONFIG{cookie_file})) { + show_session_expired_dialog(); + } + else { + $progressbar->set_text($error_message); + $progressbar->set_fraction(1.0); + toggle_progress('bar', 1); + Glib::Timeout->add(4000, sub { toggle_progress('bar', 0); 0 }); + } + return; + } display_results($result); }, $method, @@ -1932,6 +2019,38 @@ sub send_search_request { return; } +sub show_session_expired_dialog { + my $browser = $CONFIG{cookies_from_browser} // "your browser"; + my $dialog = Gtk3::MessageDialog->new( + $mainw, 'modal', 'warning', 'ok', + "YouTube session expired" + ); + $dialog->format_secondary_text( + "Your YouTube login session has expired.\n\n" + . "To fix this:\n" + . "1. Open $browser\n" + . "2. Go to youtube.com and sign out, then sign back in\n" + . "3. Come back and use Menu > Profile > Login with Browser\n\n" + . "This is required because YouTube periodically invalidates old sessions." + ); + $dialog->add_button("Open YouTube Login", 1); + $dialog->add_button("Re-extract Cookies", 2); + my $response = $dialog->run; + $dialog->destroy; + if ($response == 1) { + my %browser_cmds = ( + chromium => 'chromium-browser', chrome => 'google-chrome', + firefox => 'firefox', brave => 'brave-browser', + edge => 'microsoft-edge', opera => 'opera', vivaldi => 'vivaldi', + ); + my $cmd = $browser_cmds{$browser} // $browser; + system("$cmd 'https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26hl%3Den%26next%3D%252F' &"); + } + elsif ($response == 2) { + login_with_browser(); + } +} + sub fetch_thumbnails { my ($setter, $thumbnails, $request_ref, %request_options) = @_; $worker->abort_requests($$request_ref); @@ -2574,6 +2693,12 @@ sub check_keywords { sub search { my $keywords = $search_entry->get_text(); + # Exit shorts mode when searching + if ($in_shorts_mode) { + cancel_shorts_autoplay(); + $in_shorts_mode = 0; + } + return if check_keywords($keywords); # Remember the input text when "history" is enabled @@ -2946,12 +3071,48 @@ sub display_watched_videos { display_results(get_watched_video_results()); } +sub require_login { + my ($feature_name) = @_; + $feature_name //= "This feature"; + + if (!$CONFIG{cookies_from_browser} && !$CONFIG{cookie_file}) { + my $dialog = Gtk3::MessageDialog->new( + $mainw, 'modal', 'warning', 'ok', + "$feature_name requires YouTube login" + ); + $dialog->format_secondary_text( + "You are not logged in to YouTube.\n\n" + . "Use Menu > Profile > Login with Browser to sign in." + ); + $dialog->add_button("Login Now", 1); + my $response = $dialog->run; + $dialog->destroy; + if ($response == 1) { + login_with_browser(); + } + return 0; # not logged in + } + return 1; # logged in +} + sub display_subscription_videos { my ($refresh) = @_; $refresh //= 1; clear_search_requests(); + # If logged in, try YouTube subscription feed first + if ($CONFIG{cookies_from_browser} || $CONFIG{cookie_file}) { + send_search_request("No subscriptions found.", + 'subscription_feed', []); + return; + } + + # Not logged in — show login dialog + require_login("Subscriptions"); + return; + + # Fallback to local subscription list state $t0 = time; state $d0 = $t0; @@ -2976,6 +3137,410 @@ sub display_subscription_videos { return; } +sub display_youtube_subscriptions { + clear_search_requests(); + send_search_request("No YouTube subscription feed available. Make sure you are logged in.", + 'subscription_feed', []); + return; +} + +sub display_youtube_history { + return unless require_login("YouTube History"); + clear_search_requests(); + send_search_request("No YouTube history available.", + 'youtube_history', []); + return; +} + +sub display_youtube_trending { + # Trending works without login too + clear_search_requests(); + send_search_request("No trending videos found.", + 'trending_videos_from_category', [undef]); + return; +} + +sub display_youtube_shorts { + # Cancel any existing auto-play + cancel_shorts_autoplay(); + + $in_shorts_mode = 1; + clear_search_requests(); + send_search_request("No YouTube Shorts found.", + 'youtube_shorts', []); + + # Show keyboard hints + Glib::Timeout->add( + 2000, + sub { + if ($in_shorts_mode) { + $progressbar->set_text("Shorts mode: ↑/↓ navigate, Enter play, Esc exit"); + $progressbar->set_fraction(1.0); + toggle_progress('bar', 1); + Glib::Timeout->add(4000, sub { toggle_progress('bar', 0); 0 }); + } + return 0; + } + ); + + return; +} + +sub display_youtube_playlists { + return unless require_login("YouTube Playlists"); + clear_search_requests(); + send_search_request("No YouTube playlists found.", + 'youtube_playlists', []); + return; +} + +sub cancel_shorts_autoplay { + $shorts_autoplay_active = 0; + if (defined $shorts_autoplay_timer) { + Glib::Source->remove($shorts_autoplay_timer); + $shorts_autoplay_timer = undef; + } + if (defined $shorts_mpv_socket) { + close($shorts_mpv_socket) if $shorts_mpv_socket; + $shorts_mpv_socket = undef; + } +} + +sub watch_mpv_for_shorts_autoplay { + my ($socket_path) = @_; + + # Wait for socket to appear (mpv startup) + my $wait_count = 0; + my $wait_timer; + $wait_timer = Glib::Timeout->add( + 500, + sub { + $wait_count++; + if (-S $socket_path || $wait_count > 10) { + # Socket appeared or timed out — start watching + Glib::Source->remove($wait_timer); + start_socket_watcher($socket_path); + return 0; + } + return 1; # keep waiting + } + ); +} + +sub start_socket_watcher { + my ($socket_path) = @_; + + # Poll the socket — when mpv exits, the socket becomes unreadable + $shorts_mpv_socket = Glib::Timeout->add( + 1000, + sub { + if (!-S $socket_path) { + # Socket gone — mpv exited + $shorts_mpv_socket = undef; + unlink $socket_path if -e $socket_path; + + # Trigger auto-play after a short delay + if ($in_shorts_mode && $CONFIG{shorts_autoplay} && !$shorts_autoplay_active) { + play_next_short(2); + } + return 0; # don't repeat + } + + # Try to send a command to check if mpv is alive + if (socket(my $sock, PF_UNIX, SOCK_STREAM, 0)) { + if (connect($sock, sockaddr_un($socket_path))) { + # mpv is still running + close($sock); + return 1; # keep checking + } + } + + # Can't connect — mpv exited + $shorts_mpv_socket = undef; + unlink $socket_path if -e $socket_path; + + if ($in_shorts_mode && $CONFIG{shorts_autoplay} && !$shorts_autoplay_active) { + play_next_short(2); + } + return 0; # don't repeat + } + ); +} + +sub play_adjacent_short { + my ($direction) = @_; # -1 for previous, +1 for next + + return unless $in_shorts_mode; + + my ($iter) = $treeview->get_selection->get_selected; + return unless defined $iter; + + my $model = $treeview->get_model; + my $target_iter; + + if ($direction > 0) { + # Next + $target_iter = $model->iter_next($iter); + } + else { + # Previous — need to get the path and go back + my $path = $model->get_path($iter); + if ($path->prev) { + $target_iter = $model->get_iter($path); + } + } + + return unless defined $target_iter; + + # Select and scroll to the target + $treeview->get_selection->select_iter($target_iter); + my $path = $model->get_path($target_iter); + $treeview->scroll_to_cell($path); + + # Play the video + my $info = parse_json_string($model->get($target_iter, 8)); + if ($info) { + play_video($info, $target_iter); + } +} + +sub play_next_short { + my ($delay) = @_; + $delay //= 3; + + return unless $in_shorts_mode; + return unless $CONFIG{shorts_autoplay}; + + # Get current selection + my ($iter) = $treeview->get_selection->get_selected; + return unless defined $iter; + + # Get next iter to check if there is one + my $model = $treeview->get_model; + my $next_iter = $model->iter_next($iter); + return unless defined $next_iter; + + $shorts_autoplay_active = 1; + + # Show countdown + my $remaining = $delay; + $progressbar->set_text("Playing next Short in ${remaining}s..."); + $progressbar->set_fraction(0); + toggle_progress('bar', 1); + + $shorts_autoplay_timer = Glib::Timeout->add( + 1000, + sub { + $remaining--; + if ($remaining <= 0) { + toggle_progress('bar', 0); + $shorts_autoplay_timer = undef; + $shorts_autoplay_active = 0; + play_adjacent_short(1); + return 0; # don't repeat + } + $progressbar->set_text("Playing next Short in ${remaining}s..."); + $progressbar->set_fraction(1 - ($remaining / $delay)); + return 1; # repeat + } + ); +} + +sub login_with_browser { + + # Show browser selection dialog + my $dialog = Gtk3::Dialog->new( + "Login with Browser", + $mainw, + 'modal', + 'gtk-cancel' => 'cancel', + 'gtk-ok' => 'ok', + ); + + my $content_area = $dialog->get_content_area; + $content_area->set_border_width(12); + $content_area->set_spacing(8); + + my $label = Gtk3::Label->new("Select a browser to extract your YouTube login cookies from:"); + $label->set_line_wrap(1); + $content_area->pack_start($label, 0, 0, 8); + + # Browser selection combo + my @browsers = ('chromium', 'chrome', 'firefox', 'brave', 'edge', 'opera', 'vivaldi'); + my $combo = Gtk3::ComboBoxText->new(); + foreach my $browser (@browsers) { + $combo->append_text($browser); + } + $combo->set_active(0); # Default to chromium + $content_area->pack_start($combo, 0, 0, 4); + + # Info label + my $info = Gtk3::Label->new(""); + $info->set_line_wrap(1); + $info->set_markup('This will read cookies from your browser to authenticate with YouTube.\nThe browser must be logged into your Google/YouTube account.'); + $content_area->pack_start($info, 0, 0, 4); + + $dialog->show_all; + my $response = $dialog->run; + my $selected_browser = $browsers[$combo->get_active]; + $dialog->destroy; + + return unless $response eq 'ok'; + return unless defined $selected_browser; + + # Check if browser is running, launch if not + my %browser_cmds = ( + chromium => 'chromium-browser', + chrome => 'google-chrome', + firefox => 'firefox', + brave => 'brave-browser', + edge => 'microsoft-edge', + opera => 'opera', + vivaldi => 'vivaldi', + ); + + my %browser_procs = ( + chromium => 'chromium', + chrome => 'chrome', + firefox => 'firefox', + brave => 'brave', + edge => 'msedge', + opera => 'opera', + vivaldi => 'vivaldi', + ); + + my $proc_name = $browser_procs{$selected_browser} // $selected_browser; + my $is_running = (`pgrep -f $proc_name 2>/dev/null` ne ''); + + my $login_url = 'https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26hl%3Den%26next%3D%252F'; + my $cmd = $browser_cmds{$selected_browser} // $selected_browser; + + if (!$is_running) { + $progressbar->set_text("Launching $selected_browser..."); + $progressbar->set_fraction(0.3); + toggle_progress('bar', 1); + + # Launch browser and open YouTube login page + system("$cmd '$login_url' &"); + + # Wait for browser to start (up to 15 seconds) + my $waited = 0; + while ($waited < 15) { + sleep 1; + $waited++; + $progressbar->set_fraction(0.3 + ($waited * 0.04)); + while (Gtk3::events_pending()) { + Gtk3::main_iteration(); + } + $is_running = (`pgrep -f $proc_name 2>/dev/null` ne ''); + last if $is_running; + } + + if (!$is_running) { + my $err_dialog = Gtk3::MessageDialog->new( + $mainw, 'modal', 'warning', 'close', + "Could not launch $selected_browser." + ); + $err_dialog->format_secondary_text( + "Please open $selected_browser manually, log into YouTube, then try again." + ); + $err_dialog->run; + $err_dialog->destroy; + toggle_progress('bar', 0); + return; + } + + # Give browser time to initialize and user time to log in + $progressbar->set_text("Please log into YouTube in $selected_browser..."); + for my $i (1..8) { + sleep 1; + while (Gtk3::events_pending()) { + Gtk3::main_iteration(); + } + } + } + else { + # Browser is already running — open YouTube login page in it + system("$cmd '$login_url' &"); + $progressbar->set_text("Opening YouTube login in $selected_browser..."); + $progressbar->set_fraction(0.5); + toggle_progress('bar', 1); + for my $i (1..3) { + sleep 1; + while (Gtk3::events_pending()) { + Gtk3::main_iteration(); + } + } + } + + # Show progress + $progressbar->set_text("Extracting cookies from $selected_browser..."); + $progressbar->set_fraction(0.5); + toggle_progress('bar', 1); + + # Force re-extraction by deleting cache + my $cache_dir = "$ENV{HOME}/.cache/pipe-viewer"; + my $cache_file = "$cache_dir/cookies_from_${selected_browser}.txt"; + unlink $cache_file if -f $cache_file; + + # Update config + $CONFIG{cookies_from_browser} = $selected_browser; + + # Extract cookies using the worker + $worker->send_request( + sub { + my ($result) = @_; + toggle_progress('bar', 0); + + # reload_cookies_from_browser returns 1 on success, 0 on failure + my $profile_loaded = $result; + + if ($profile_loaded) { + $progressbar->set_text("Logged in with $selected_browser"); + $progressbar->set_fraction(1.0); + toggle_progress('bar', 1); + + # Save to config + $CONFIG{cookies_from_browser} = $selected_browser; + + Glib::Timeout->add( + 5000, + sub { + toggle_progress('bar', 0); + return 0; + } + ); + } + else { + my $err_dialog = Gtk3::MessageDialog->new( + $mainw, + 'modal', + 'warning', + 'ok', + "Not logged in to YouTube" + ); + $err_dialog->format_secondary_text( + "Could not find valid YouTube login cookies in $selected_browser.\n\n" + . "Please:\n" + . "1. Open $selected_browser\n" + . "2. Go to youtube.com and sign in to your Google account\n" + . "3. Come back here and try Login with Browser again" + ); + # Add a button to open YouTube login page + $err_dialog->add_button("Open YouTube Login", 1); + my $response = $err_dialog->run; + $err_dialog->destroy; + if ($response == 1) { + my $cmd = $browser_cmds{$selected_browser} // $selected_browser; + system("$cmd 'https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26hl%3Den%26next%3D%252F' &"); + } + } + }, + 'reload_cookies_from_browser', + [$selected_browser], + ); +} + sub save_search_results_position { return if $ResultsHistory{current} < 0; my $position = $treeview->get_vadjustment->get_value; @@ -3489,6 +4054,13 @@ sub play_video { my $command = get_player_command($streaming, $info); + # Add IPC socket for shorts auto-play detection + my $ipc_socket; + if ($in_shorts_mode && $CONFIG{shorts_autoplay}) { + $ipc_socket = "/tmp/mpv-pipe-viewer-$$.sock"; + $command =~ s{(/usr/bin/mpv|mpv)}{$1 --input-ipc-server=$ipc_socket}; + } + if ($DEBUG) { say "-> Resolution: $streaming->{resolution}"; say "-> Video itag: $streaming->{streaming}{itag}"; @@ -3506,6 +4078,11 @@ sub play_video { } save_watched_video($info, $iter); + + # Start watching for mpv exit (shorts auto-play) + if ($in_shorts_mode && $CONFIG{shorts_autoplay} && defined $ipc_socket) { + watch_mpv_for_shorts_autoplay($ipc_socket); + } }, 'fetch_streaming_urls', [$video_id, \%options], diff --git a/bin/pipe-viewer b/bin/pipe-viewer index b98db2f..bcaafa9 100644 --- a/bin/pipe-viewer +++ b/bin/pipe-viewer @@ -249,8 +249,10 @@ my %CONFIG = ( # Misc options autoplay_mode => 0, http_proxy => undef, - cookie_file => undef, - user_agent => undef, + cookie_file => undef, + cookies_from_browser => undef, + yt_subs => 0, + user_agent => undef, timeout => undef, max_retries => 3, retry_delay => 1, @@ -765,6 +767,7 @@ my $yv_obj = WWW::PipeViewer->new( cache_dir => $opt{cache_dir}, env_proxy => $opt{env_proxy}, cookie_file => $opt{cookie_file}, + cookies_from_browser => $opt{cookies_from_browser}, http_proxy => $opt{http_proxy}, user_agent => $opt{user_agent}, timeout => $opt{timeout}, @@ -978,6 +981,7 @@ usage: $execname [options] ([url] | [keywords]) --skip-youtube-extraction! : Skip video/audio URL extraction, pass YouTube URL directly to player --dislikes-api! : Enable dislikes from https://returnyoutubedislike.com --cookies=s : file to read cookies from and dump cookie + --cookies-from-browser=s : extract cookies from a browser (chromium, firefox, chrome, brave, edge, opera, vivaldi) --user-agent=s : specify a custom user agent --proxy=s : set HTTP(S)/SOCKS proxy: 'proto://domain.tld:port/' If authentication is required, @@ -1254,7 +1258,7 @@ sub apply_configuration { channelId region debug http_proxy page comments_order user_agent force_fallback - cookie_file timeout ytdl ytdl_cmd + cookie_file cookies_from_browser timeout ytdl ytdl_cmd prefer_mp4 prefer_av1 prefer_invidious ytdlp_comments ytdlp_max_comments ytdlp_max_replies @@ -1529,6 +1533,7 @@ sub apply_configuration { if (defined $opt->{trending}) { my $cat_id = delete $opt->{trending}; + $cat_id = undef if defined($cat_id) && $cat_id eq ''; print_videos($yv_obj->trending_videos_from_category($cat_id)); } @@ -1660,6 +1665,8 @@ sub parse_arguments { 'popular-shorts|pshorts=s' => \$opt{popular_shorts}, 'cookie-file|cookies=s' => \$opt{cookie_file}, + 'cookies-from-browser=s' => \$opt{cookies_from_browser}, + 'yt-subs' => \$opt{yt_subs}, 'user-agent|agent=s' => \$opt{user_agent}, 'http-proxy|https-proxy|proxy=s' => \$opt{http_proxy}, @@ -4954,6 +4961,28 @@ See also: https://github.com/ytdl-org/youtube-dl#how-do-i-pass-cookies-to-youtube-dl +=head2 cookies_from_browser + +Automatically extract cookies from an installed browser using C. + +Supported values: C, C, C, C, C, C, C. + +When set, cookies are extracted on startup and cached for 1 hour. This allows access to +logged-in content, age-restricted videos, and personalized results without manually +exporting a cookie file. + +Example usage: + + pipe-viewer --cookies-from-browser=chromium + +Or in the config file: + + cookies_from_browser => "chromium", + +Note: C takes precedence over C if both are set. + +Requires C to be installed. + =head2 copy_caption When downloading a video, copy the closed-caption (if any) into the same folder with the video. diff --git a/lib/WWW/PipeViewer.pm b/lib/WWW/PipeViewer.pm index 60f1678..0a6145e 100644 --- a/lib/WWW/PipeViewer.pm +++ b/lib/WWW/PipeViewer.pm @@ -81,7 +81,8 @@ my %valid_options = ( retry_delay => {valid => qr/^\d+\z/, default => 1}, config_dir => {valid => qr/^./, default => q{.}}, cache_dir => {valid => qr/^./, default => q{.}}, - cookie_file => {valid => qr/^./, default => undef}, + cookie_file => {valid => qr/^./, default => undef}, + cookies_from_browser => {valid => qr/^\w+/, default => undef}, # Support for yt-dlp / youtube-dl ytdl => {valid => [1, 0], default => 1}, @@ -135,6 +136,11 @@ sub new { warn "Invalid key: '${invalid_key}'"; } + # Eagerly load cookies if cookies_from_browser is set + if (defined($self->{cookies_from_browser})) { + $self->{lwp} // $self->set_lwp_useragent(); + } + return $self; } @@ -329,6 +335,12 @@ sub _setup_cookies { my ($self, $agent) = @_; my $cookie_file = $self->get_cookie_file; + my $browser = $self->get_cookies_from_browser; + + # If cookies_from_browser is set, extract cookies + if (defined($browser) and !defined($cookie_file)) { + $cookie_file = $self->_extract_browser_cookies($browser); + } if (defined($cookie_file) and -f $cookie_file) { if ($self->get_debug) { @@ -343,6 +355,11 @@ sub _setup_cookies { ); $cookies->load; $agent->cookie_jar($cookies); + + # Verify profile if cookies_from_browser is set + if (defined($browser)) { + $self->_verify_profile($agent); + } } else { require HTTP::Cookies; @@ -352,6 +369,165 @@ sub _setup_cookies { } } +sub _extract_browser_cookies { + my ($self, $browser) = @_; + + require File::Spec; + + my $cache_dir = $self->get_cache_dir // File::Spec->tmpdir; + my $cookie_file = File::Spec->catfile($cache_dir, "cookies_from_${browser}.txt"); + + # Re-use cached file if it exists and is less than 1 hour old + if (-f $cookie_file && (time() - (stat($cookie_file))[9]) < 3600) { + if ($self->get_debug) { + say STDERR ":: Reusing cached browser cookies: $cookie_file"; + } + $self->{profile_loaded} = 1; + return $cookie_file; + } + + if ($self->get_debug) { + say STDERR ":: Extracting cookies from browser: $browser"; + } + + # Try the bundled Python helper first (fast, native decryption) + my $helper_script = $self->_find_cookie_helper_script(); + + if (defined($helper_script)) { + my $exit_code = system('python3', $helper_script, $browser, $cookie_file); + + if ($exit_code == 0 && -f $cookie_file && -s $cookie_file) { + if ($self->get_debug) { + say STDERR ":: Browser cookies extracted to: $cookie_file"; + } + $self->{profile_loaded} = 1; + return $cookie_file; + } + + warn ":: Warning: Python cookie extraction failed for '$browser', falling back to yt-dlp\n"; + } + + # Fallback to yt-dlp + my $ytdl_cmd = $self->get_ytdl_cmd // 'yt-dlp'; + + my @cmd = ( + $ytdl_cmd, + '--cookies-from-browser', $browser, + '--cookies', $cookie_file, + '--skip-download', + '--print', '', + 'https://www.youtube.com', + ); + + my $exit_code = system(@cmd); + + if ($exit_code == 0 && -f $cookie_file && -s $cookie_file) { + if ($self->get_debug) { + say STDERR ":: Browser cookies extracted to: $cookie_file"; + } + $self->{profile_loaded} = 1; + return $cookie_file; + } + + warn ":: Warning: failed to extract cookies from browser '$browser' (exit code: $exit_code)\n"; + $self->{profile_loaded} = 0; + return undef; +} + +sub _find_cookie_helper_script { + my ($self) = @_; + + require File::Spec; + + # Look relative to the pipe-viewer binary + require FindBin; + my $bin_dir = $FindBin::Bin || '/usr/local/bin'; + foreach my $subdir ('', '..', 'utils', '../utils', '../share/pipe-viewer') { + my $path = File::Spec->catfile($bin_dir, $subdir, 'extract-browser-cookies.py'); + if (-f $path) { + return $path; + } + } + + # Check standard install locations + foreach my $dir ('/usr/local/share/pipe-viewer', '/usr/share/pipe-viewer') { + my $path = File::Spec->catfile($dir, 'extract-browser-cookies.py'); + if (-f $path) { + return $path; + } + } + + return undef; +} + +sub _verify_profile { + my ($self, $agent) = @_; + + my $jar = $agent->cookie_jar; + my $has_auth = 0; + + # Check for YouTube auth cookies + $jar->scan(sub { + my ($version, $key, $val, $path, $domain, $port, $path_spec, $secure, $expires, $discard, $hash) = @_; + if ($domain =~ /youtube\.com|google\.com/ && $key =~ /^(SID|SAPISID|HSID|SSID|__Secure-3PSID|__Secure-3PAPISID|__Secure-1PSID)$/) { + $has_auth = 1; + } + }); + + if ($has_auth) { + $self->{profile_loaded} = 1; + if ($self->get_debug) { + say STDERR ":: Profile verified: auth cookies present"; + } + } + else { + $self->{profile_loaded} = 0; + warn ":: Warning: cookies loaded but no auth cookies found. Profile may not be authenticated.\n"; + } +} + +sub get_profile_loaded { + my ($self) = @_; + return $self->{profile_loaded} // 0; +} + +sub get_profile_username { + my ($self) = @_; + return $self->{profile_username} // undef; +} + +sub reload_cookies_from_browser { + my ($self, $browser) = @_; + + require File::Spec; + my $cache_dir = $self->get_cache_dir // File::Spec->tmpdir; + my $cookie_file = File::Spec->catfile($cache_dir, "cookies_from_${browser}.txt"); + + # Delete cached file to force re-extraction + unlink $cookie_file if -f $cookie_file; + + $self->set_cookies_from_browser($browser); + my $result = $self->_extract_browser_cookies($browser); + + if (defined($result) && -f $result) { + # Re-setup the LWP agent with new cookies + my $agent = $self->{lwp}; + if ($agent) { + require HTTP::Cookies::Netscape; + my $cookies = HTTP::Cookies::Netscape->new( + hide_cookie2 => 1, + autosave => 1, + file => $result, + ); + $cookies->load; + $agent->cookie_jar($cookies); + $self->_verify_profile($agent); + } + } + + return $self->{profile_loaded} // 0; +} + sub _set_default_cookies { my ($self, $cookies) = @_; diff --git a/lib/WWW/PipeViewer/InitialData.pm b/lib/WWW/PipeViewer/InitialData.pm index 574f227..e367da1 100644 --- a/lib/WWW/PipeViewer/InitialData.pm +++ b/lib/WWW/PipeViewer/InitialData.pm @@ -919,7 +919,7 @@ sub _get_initial_data { my $content = $self->lwp_get($url) // return; - # Try to extract from JavaScript variable + # Try to extract from JavaScript variable (single-quoted) if ($content =~ m{var\s+ytInitialData\s*=\s*'(.*?)'}is) { my $json = $1; @@ -931,6 +931,13 @@ sub _get_initial_data { return $hash; } + # Try to extract from JavaScript variable (raw JSON, no quotes) + if ($content =~ m{var\s+ytInitialData\s*=\s*(\{.+?)\s*;\s*}is) { my $json = $1; @@ -1247,6 +1254,618 @@ sub yt_search { return $self->_prepare_results_for_return(\@results, %args, url => $url); } +=head2 yt_youtube_trending(%args) + +Fetch YouTube trending videos by scraping. + +=cut + +sub yt_youtube_trending { + my ($self, %args) = @_; + + my $url = 'https://m.youtube.com/feed/trending'; + my %params = (hl => 'en'); + $url = $self->_append_url_args($url, %params); + + my $hash = $self->_get_initial_data($url) // return; + + my $contents = do { + my $result = []; + my $tabs = $hash->{contents}{twoColumnBrowseResultsRenderer}{tabs} // []; + for my $tab (@$tabs) { + for my $key (qw(richGridRenderer sectionListRenderer)) { + my $c = eval { $tab->{tabRenderer}{content}{$key}{contents} }; + if ($c && @$c) { $result = $c; last; } + } + last if @$result; + } + if (!@$result) { + $tabs = $hash->{contents}{singleColumnBrowseResultsRenderer}{tabs} // []; + for my $tab (@$tabs) { + for my $key (qw(richGridRenderer sectionListRenderer)) { + my $c = eval { $tab->{tabRenderer}{content}{$key}{contents} }; + if ($c && @$c) { $result = $c; last; } + } + last if @$result; + } + } + $result; + }; + + # Flatten: handle richItemRenderer and richSectionRenderer + my @flat; + for my $item (@$contents) { + if ($item->{richItemRenderer}) { + push @flat, $item; + } + elsif ($item->{richSectionRenderer}) { + my $content = $item->{richSectionRenderer}{content} // {}; + for my $key (keys %$content) { + my $shelf = $content->{$key}; + next unless ref($shelf) eq "HASH"; + for my $sub (@{$shelf->{contents} // []}) { + if ($sub->{richItemRenderer}) { + push @flat, $sub; + } + elsif ($sub->{videoWithContextRenderer} || $sub->{videoRenderer}) { + push @flat, {richItemRenderer => {content => $sub}}; + } + } + } + } + elsif ($item->{itemSectionRenderer}) { + for my $sub (@{$item->{itemSectionRenderer}{contents} // []}) { + if ($sub->{richGridRenderer}) { + push @flat, @{$sub->{richGridRenderer}{contents} // []}; + } + elsif ($sub->{videoWithContextRenderer} || $sub->{videoRenderer}) { + push @flat, {richItemRenderer => {content => $sub}}; + } + } + } + } + + return $self->_extract_videos_from_richGrid(\@flat, %args, url => $url); +} + +=head2 yt_subscription_feed(%args) + +Fetch the YouTube subscription feed. Requires cookies from a logged-in browser. + +=cut + +sub yt_subscription_feed { + my ($self, %args) = @_; + + my $url = 'https://m.youtube.com/feed/subscriptions'; + + my %params = ( + hl => 'en', + ); + + $url = $self->_append_url_args($url, %params); + + my $hash = $self->_get_initial_data($url) // return; + + # Try to find richGridRenderer in various locations + my $contents = do { + my $result = []; + # twoColumnBrowseResultsRenderer (desktop) + my $tabs = $hash->{contents}{twoColumnBrowseResultsRenderer}{tabs} // []; + for my $tab (@$tabs) { + my $c = eval { $tab->{tabRenderer}{content}{richGridRenderer}{contents} }; + if ($c && @$c) { $result = $c; last; } + } + # singleColumnBrowseResultsRenderer (mobile) + if (!@$result) { + $tabs = $hash->{contents}{singleColumnBrowseResultsRenderer}{tabs} // []; + for my $tab (@$tabs) { + my $c = eval { $tab->{tabRenderer}{content}{richGridRenderer}{contents} }; + if ($c && @$c) { $result = $c; last; } + } + } + # Direct access + if (!@$result) { + my $c = eval { $hash->{contents}{richGridRenderer}{contents} }; + $result = $c if $c && @$c; + } + $result; + }; + + # Extract videos from all items including richSectionRenderer + my @results; + for my $item (@$contents) { + # Regular videos + if ($item->{richItemRenderer}) { + my $vid = $item->{richItemRenderer}{content}{videoWithContextRenderer} + // $item->{richItemRenderer}{content}{videoRenderer}; + if ($vid) { + my $video = $self->_parse_video_renderer($vid); + push @results, $video if $video; + } + } + # richSectionRenderer (contains Shorts shelf) + elsif ($item->{richSectionRenderer}) { + my $section = $item->{richSectionRenderer}{content} // {}; + for my $key (keys %$section) { + my $shelf = $section->{$key}; + next unless ref($shelf) eq "HASH"; + my $items = $shelf->{items} // $shelf->{contents} // []; + for my $sub (@$items) { + # Shorts use shortsLockupViewModel + my $slvm = $sub->{shortsLockupViewModel}; + if ($slvm) { + # Extract video ID from thumbnail URL + my $thumb_url = $slvm->{thumbnailViewModel}{thumbnailViewModel}{image}{sources}[0]{url} // ""; + my ($video_id) = $thumb_url =~ m{/vi/([^/]+)/}; + next unless $video_id; + my $access_text = $slvm->{accessibilityText} // ""; + # Extract title - it's between quotes in accessibility text + # Format: Воспроизвести короткое видео "Title, view count" + my $title = ""; + if ($access_text =~ /\x{201c}(.+?)\x{201d}/ || $access_text =~ /"(.+?)"/) { + $title = $1; + # Remove view count from end (e.g., ", 1,1 тысячи просмотров") + $title =~ s/,\s*\d+[\d,.\s]*\S*\s*(тысяч[а-я]*|миллион[а-я]*|просмотр[а-я]*|views|vueltas).*//i; + } + next unless $title; + push @results, { + type => 'video', + title => $title, + videoId => $video_id, + author => '', + lengthSeconds => 0, + viewCount => 0, + published => undef, + publishedText => '', + liveNow => 0, + videoThumbnails => [ + {quality => 'medium', url => "https://i.ytimg.com/vi/$video_id/default.jpg", width => 120, height => 90}, + ], + } if $title; + next; + } + # Regular video in shelf + my $vid = $sub->{richItemRenderer}{content}{videoWithContextRenderer} + // $sub->{richItemRenderer}{content}{videoRenderer}; + if ($vid) { + my $video = $self->_parse_video_renderer($vid); + push @results, $video if $video; + } + } + } + } + # Continuation (skip for now) + elsif ($item->{continuationItemRenderer}) { + # TODO: implement continuation pagination + } + } + + return $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +=head2 yt_youtube_shorts(%args) + +Fetch YouTube Shorts using yt-dlp with the Shorts filter. + +=cut + +sub yt_youtube_shorts { + my ($self, %args) = @_; + + my $ytdl_cmd = $self->get_ytdl_cmd // 'yt-dlp'; + my $cookie_file = $self->_find_cookie_file(); + + # Use yt-dlp to search for Shorts (videos under 60s, vertical format) + # sp=EgIYAQ%3D%3D is the protobuf filter for Shorts + my @cmd = ($ytdl_cmd, '--flat-playlist', '--print', '%(id)s|||%(title)s|||%(channel)s|||%(duration)s'); + push @cmd, '--cookies', $cookie_file if $cookie_file; + push @cmd, 'https://www.youtube.com/results?search_query=shorts&sp=EgIYAQ%253D%253D'; + + my $cmd_str = join(' ', map { quotemeta($_) } @cmd); + my $output = `$cmd_str 2>/dev/null`; + return unless $output; + + my @results; + my %seen; + for my $line (split /\n/, $output) { + chomp $line; + my ($id, $title, $channel, $duration) = split /\|\|\|/, $line; + next unless $id && $title; + next if $seen{$id}++; + + # Only include videos under 60 seconds (actual Shorts) + next if $duration && $duration > 60; + + push @results, { + type => 'video', + title => $title, + videoId => $id, + author => $channel // '', + authorId => '', + lengthSeconds => $duration // 0, + viewCount => 0, + published => undef, + publishedText => '', + liveNow => 0, + paid => 0, + premium => 0, + videoThumbnails => [ + {quality => 'medium', url => "https://i.ytimg.com/vi/$id/default.jpg", width => 120, height => 90}, + ], + }; + } + + return $self->_prepare_results_for_return(\@results, %args, url => 'https://www.youtube.com/results?search_query=shorts'); +} + +=head2 yt_youtube_playlists(%args) + +Fetch the user's YouTube playlists. Requires cookies from a logged-in browser. + +=cut + +sub yt_youtube_playlists { + my ($self, %args) = @_; + + # Use yt-dlp to extract playlists (page loads dynamically) + my $ytdl_cmd = $self->get_ytdl_cmd // 'yt-dlp'; + my $cookie_file = $self->_find_cookie_file(); + + my @cmd = ($ytdl_cmd, '--flat-playlist', '--print', '%(title)s|||%(id)s|||%(playlist_count)s'); + push @cmd, '--cookies', $cookie_file if $cookie_file; + push @cmd, 'https://www.youtube.com/feed/playlists'; + + my $cmd_str = join(' ', map { quotemeta($_) } @cmd); + my $output = `$cmd_str 2>/dev/null`; + return unless $output; + + my @results; + for my $line (split /\n/, $output) { + chomp $line; + my ($title, $id, $count) = split /\|\|\|/, $line; + next unless $title && $id; + + # Determine type + my $type = 'playlist'; + if ($id eq 'LL') { + $type = 'special'; + $title = 'Liked videos'; + } + elsif ($id eq 'WL') { + $type = 'special'; + $title = 'Watch later'; + } + + push @results, { + type => $type, + title => $title, + playlistId => $id, + author => '', + videoCount => ($count // 0), + }; + } + + return $self->_prepare_results_for_return(\@results, %args, url => 'https://www.youtube.com/feed/playlists'); +} + +sub _find_cookie_file { + my ($self) = @_; + + my $cookie_file = $self->get_cookie_file; + return $cookie_file if $cookie_file && -f $cookie_file; + + my $browser = $self->get_cookies_from_browser; + + require File::Spec; + my $default_dir = File::Spec->catdir($ENV{HOME} // '.', '.cache', 'pipe-viewer'); + + # If browser specified, look for its specific cookie file + if ($browser) { + # Check cache_dir + my $cache_dir = $self->get_cache_dir; + if ($cache_dir && $cache_dir ne '.') { + my $cf = File::Spec->catfile($cache_dir, "cookies_from_${browser}.txt"); + return $cf if -f $cf; + } + + my $cf = File::Spec->catfile($default_dir, "cookies_from_${browser}.txt"); + return $cf if -f $cf; + + $cf = File::Spec->catfile('.', "cookies_from_${browser}.txt"); + return $cf if -f $cf; + } + + # Fallback: find ANY cookies_from_*.txt in default cache + if (opendir(my $dh, $default_dir)) { + my @cookie_files = grep { /^cookies_from_.*\.txt$/ } readdir($dh); + closedir($dh); + if (@cookie_files) { + # Prefer the one matching browser, or just take the first + my $cf = File::Spec->catfile($default_dir, $cookie_files[0]); + return $cf if -f $cf; + } + } + + return undef; +} + +=head2 yt_youtube_history(%args) + +Fetch the YouTube watch history. Requires cookies from a logged-in browser. + +=cut + +sub yt_youtube_history { + my ($self, %args) = @_; + + my $url = 'https://m.youtube.com/feed/history'; + + my %params = ( + hl => 'en', + ); + + $url = $self->_append_url_args($url, %params); + + my $hash = $self->_get_initial_data($url) // return; + + # History uses sectionListRenderer + my $contents = do { + my $result = []; + my $tabs = $hash->{contents}{twoColumnBrowseResultsRenderer}{tabs} // []; + for my $tab (@$tabs) { + my $c = eval { $tab->{tabRenderer}{content}{sectionListRenderer}{contents} }; + if ($c && @$c) { $result = $c; last; } + } + if (!@$result) { + $tabs = $hash->{contents}{singleColumnBrowseResultsRenderer}{tabs} // []; + for my $tab (@$tabs) { + my $c = eval { $tab->{tabRenderer}{content}{sectionListRenderer}{contents} }; + if ($c && @$c) { $result = $c; last; } + } + } + if (!@$result) { + my $c = eval { $hash->{contents}{sectionListRenderer}{contents} }; + $result = $c if $c && @$c; + } + $result; + }; + + my @results; + for my $item (@$contents) { + # itemSectionRenderer contains the video list + my $isr_items = eval { $item->{itemSectionRenderer}{contents} } // []; + for my $isr_item (@$isr_items) { + # compactVideoRenderer (history format) + my $cvr = $isr_item->{compactVideoRenderer}; + if ($cvr) { + my $video = $self->_parse_compact_video_renderer($cvr); + push @results, $video if $video; + next; + } + # richGridRenderer + my $rgr = $isr_item->{richGridRenderer}; + if ($rgr) { + for my $ri (@{$rgr->{contents} // []}) { + my $vid = $ri->{richItemRenderer}{content}{videoWithContextRenderer} + // $ri->{richItemRenderer}{content}{videoRenderer}; + next unless $vid; + my $video = $self->_parse_video_renderer($vid); + push @results, $video if $video; + } + } + # Direct videoRenderer + my $vid = $isr_item->{videoWithContextRenderer} + // $isr_item->{videoRenderer}; + if ($vid) { + my $video = $self->_parse_video_renderer($vid); + push @results, $video if $video; + } + } + } + + return $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +sub _parse_compact_video_renderer { + my ($self, $cvr) = @_; + + my $title = $cvr->{title}{runs}[0]{text} // return undef; + my $videoId = $cvr->{videoId} // return undef; + my $author = $cvr->{shortBylineText}{runs}[0]{text} // ''; + my $authorId = eval { $cvr->{shortBylineText}{runs}[0]{navigationEndpoint}{browseEndpoint}{browseId} } // ''; + + my $viewCount = 0; + my $viewText = $cvr->{viewCountText}{simpleText} // ''; + if ($viewText =~ /^([\d,.]+[KMB]?)\s*views/i) { + $viewCount = WWW::PipeViewer::InitialData::_human_number_to_int($1); + } + + my $lengthSeconds = 0; + if (($cvr->{lengthText}{runs}[0]{text} // $cvr->{lengthText}{simpleText} // '') =~ /([\d:]+)/) { + $lengthSeconds = WWW::PipeViewer::InitialData::_time_to_seconds($1); + } + + return { + type => "video", + title => $title, + videoId => $videoId, + author => $author, + authorId => $authorId, + viewCount => $viewCount, + published => undef, + publishedText => '', + lengthSeconds => $lengthSeconds, + liveNow => ($lengthSeconds == 0), + paid => 0, + premium => 0, + videoThumbnails => [ + map { + scalar { + quality => 'medium', + url => ($_->{url} =~ s{/hqdefault\.jpg}{/mqdefault.jpg}r), + width => $_->{width}, + height => $_->{height}, + } + } @{$cvr->{thumbnail}{thumbnails} // []} + ], + }; +} + +sub _extract_videos_from_richGrid { + my ($self, $contents, %args) = @_; + + my @results; + for my $item (@$contents) { + my $content = $item->{richItemRenderer}{content} // {}; + + # New YouTube format: lockupViewModel + my $lvm = $content->{lockupViewModel}; + if ($lvm && ($lvm->{contentType} // '') =~ /VIDEO/) { + my $video = $self->_parse_lockup_view_model($lvm); + push @results, $video if $video; + next; + } + + # Legacy format + my $vid = $content->{videoWithContextRenderer} + // $content->{videoRenderer}; + next unless $vid; + my $video = $self->_parse_video_renderer($vid); + push @results, $video if $video; + } + + return $self->_prepare_results_for_return(\@results, %args); +} + +sub _parse_lockup_view_model { + my ($self, $lvm) = @_; + + my $videoId = $lvm->{contentId} // return undef; + my $meta = $lvm->{metadata}{lockupMetadataViewModel} // {}; + + my $title = $meta->{title}{content} // return undef; + + # Extract thumbnail + my $thumb_url = $lvm->{contentImage}{thumbnailViewModel}{image}{sources}[0]{url} + // "https://i.ytimg.com/vi/$videoId/default.jpg"; + + # Extract metadata (views, author, etc.) + my $viewCount = 0; + my $author = ''; + my $publishedText = ''; + my $lengthSeconds = 0; + + if ($meta->{metadata}{metadataRows}) { + for my $row (@{$meta->{metadata}{metadataRows}}) { + for my $part (@{$row->{metadataParts}}) { + my $text = $part->{text}{content} // ''; + if ($text =~ /^([\d,.]+[KMB]?)\s*views/i) { + $viewCount = WWW::PipeViewer::InitialData::_human_number_to_int($1); + } + elsif ($text =~ /\d+:\d+/) { + $lengthSeconds = WWW::PipeViewer::InitialData::_time_to_seconds($text); + } + elsif ($text =~ /\d+\s+\w+\s+ago/) { + $publishedText = $text; + } + elsif (!$author && $text && $text !~ /^\d/ && length($text) > 2) { + $author = $text; + } + } + } + } + + return { + type => 'video', + title => $title, + videoId => $videoId, + author => $author, + authorId => '', + viewCount => $viewCount, + published => undef, + publishedText => $publishedText, + lengthSeconds => $lengthSeconds, + liveNow => 0, + paid => 0, + premium => 0, + videoThumbnails => [ + {quality => 'medium', url => $thumb_url =~ s{/hqdefault\.jpg}{/mqdefault.jpg}r, width => 320, height => 180}, + ], + }; +} + +sub _parse_video_renderer { + my ($self, $vid) = @_; + + my $title = $vid->{headline}{runs}[0]{text} + // $vid->{title}{runs}[0]{text} + // return undef; + + my $videoId = $vid->{videoId} // return undef; + + my $author = $vid->{shortBylineText}{runs}[0]{text} // ''; + my $authorId = eval { $vid->{shortBylineText}{runs}[0]{navigationEndpoint}{browseEndpoint}{browseId} } // ''; + + my $viewCount = 0; + my $viewText = $vid->{shortViewCountText}{runs}[0]{text} + // $vid->{viewCountText}{simpleText} + // ''; + if ($viewText =~ /^([\d,.]+[KMB]?)\s*views/i) { + $viewCount = WWW::PipeViewer::InitialData::_human_number_to_int($1); + } + + my $lengthSeconds = 0; + if (($vid->{lengthText}{runs}[0]{text} // $vid->{lengthText}{simpleText} // '') =~ /([\d:]+)/) { + $lengthSeconds = WWW::PipeViewer::InitialData::_time_to_seconds($1); + } + + my $publishedText = $vid->{publishedTimeText}{simpleText} // ''; + my $published = undef; + + if ($publishedText =~ /(\d+)\s+(\w+)\s+ago/) { + my ($quantity, $period) = ($1, $2); + $period =~ s/s\z//; + my %table = ( + year => 31556952, + month => 2629743.83, + week => 604800, + day => 86400, + hour => 3600, + minute => 60, + second => 1, + ); + if (exists $table{$period}) { + $published = int(time - $quantity * $table{$period}); + } + } + + return { + type => "video", + title => $title, + videoId => $videoId, + author => $author, + authorId => $authorId, + viewCount => $viewCount, + published => $published, + publishedText => $publishedText, + lengthSeconds => $lengthSeconds, + liveNow => ($lengthSeconds == 0), + paid => 0, + premium => 0, + videoThumbnails => [ + map { + scalar { + quality => 'medium', + url => ($_->{url} =~ s{/hqdefault\.jpg}{/mqdefault.jpg}r), + width => $_->{width}, + height => $_->{height}, + } + } @{$vid->{thumbnail}{thumbnails} // []} + ], + }; +} + =head2 yt_channel_search($channel, q => $keyword, %args) Search for videos given a keyword string from a channel ID or username. diff --git a/lib/WWW/PipeViewer/Utils.pm b/lib/WWW/PipeViewer/Utils.pm index a0c7ff6..433413b 100644 --- a/lib/WWW/PipeViewer/Utils.pm +++ b/lib/WWW/PipeViewer/Utils.pm @@ -113,6 +113,7 @@ sub format_time { my ($self, $sec) = @_; $sec //= 0; + $sec = 0 if $sec !~ /^\d+$/; $sec >= 3600 ? join q{:}, map { sprintf '%02d', $_ } $sec / 3600 % 24, $sec / 60 % 60, $sec % 60 diff --git a/lib/WWW/PipeViewer/Videos.pm b/lib/WWW/PipeViewer/Videos.pm index fcbf4c3..fc63992 100644 --- a/lib/WWW/PipeViewer/Videos.pm +++ b/lib/WWW/PipeViewer/Videos.pm @@ -43,9 +43,82 @@ sub trending_videos_from_category { $category = $_CATEGORIES{$category}; } + # Try YouTube scraping first + if (my $results = $self->yt_youtube_trending()) { + return $results; + } + + # Fallback to Invidious API return $self->_get_results($self->_make_feed_url('trending', (defined($category) ? (type => $category) : ()))); } +=head2 subscription_feed(%args) + +Get videos from the YouTube subscription feed. +Requires cookies_from_browser or cookie_file to be set with a logged-in session. + +=cut + +sub subscription_feed { + my ($self, %args) = @_; + + if (my $results = $self->yt_subscription_feed(%args)) { + return $results; + } + + return {results => [], url => undef}; +} + +=head2 youtube_history(%args) + +Get videos from the YouTube watch history. +Requires cookies_from_browser or cookie_file to be set with a logged-in session. + +=cut + +sub youtube_history { + my ($self, %args) = @_; + + if (my $results = $self->yt_youtube_history(%args)) { + return $results; + } + + return {results => [], url => undef}; +} + +=head2 youtube_shorts(%args) + +Get YouTube Shorts (vertical short videos under 60 seconds). + +=cut + +sub youtube_shorts { + my ($self, %args) = @_; + + if (my $results = $self->yt_youtube_shorts(%args)) { + return $results; + } + + return {results => [], url => undef}; +} + +=head2 youtube_playlists(%args) + +Get the user's YouTube playlists. +Requires cookies_from_browser or cookie_file to be set with a logged-in session. + +=cut + +sub youtube_playlists { + my ($self, %args) = @_; + + if (my $results = $self->yt_youtube_playlists(%args)) { + return $results; + } + + return {results => [], url => undef}; +} + =head2 videos_details($id, $part) Get info about a videoID, such as: channelId, title, description, diff --git a/share/gtk-pipe-viewer.glade b/share/gtk-pipe-viewer.glade index 08bce1b..f043e40 100644 --- a/share/gtk-pipe-viewer.glade +++ b/share/gtk-pipe-viewer.glade @@ -269,16 +269,100 @@ Author: Trizen https://github.com/trizen - - Subscription videos - True + True False - Display local subscription videos - image12 - False - - + Profile + + + True + False + + + Login with Browser... + True + False + Extract cookies from a browser to access your YouTube account + image12 + False + + + + + + + True + False + + + + + Subscriptions + True + True + False + Display your YouTube subscription feed + image12 + False + + + + + + + History + True + False + Display your YouTube watch history + image12 + False + + + + + + + Playlists + True + False + Display your YouTube playlists + image12 + False + + + + + + + True + False + + + + + Trending + True + False + Display trending videos on YouTube + image12 + False + + + + + + + Shorts + True + False + Display YouTube Shorts (vertical short videos with auto-play) + image12 + False + + + + + diff --git a/utils/extract-browser-cookies.py b/utils/extract-browser-cookies.py new file mode 100644 index 0000000..524978c --- /dev/null +++ b/utils/extract-browser-cookies.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Extract cookies from Chromium-based browsers using the GNOME keyring. + +Usage: extract-browser-cookies.py BROWSER [OUTPUT_FILE] + +BROWSER: chromium, chrome, brave, vivaldi, edge, opera +""" + +import os, sys, sqlite3, shutil, tempfile +from pathlib import Path + +BROWSER_DB_PATHS = { + "chromium": "chromium/Default/Cookies", + "chrome": "google-chrome/Default/Cookies", + "brave": "BraveSoftware/Brave-Browser/Default/Cookies", + "vivaldi": "vivaldi/Default/Cookies", + "edge": "microsoft-edge/Default/Cookies", + "opera": "opera/Cookies", +} + +BROWSER_KEYRING_LABEL = { + "chromium": "Chromium Safe Storage", + "chrome": "Chrome Safe Storage", + "brave": "Brave Safe Storage", + "vivaldi": "Chrome Safe Storage", + "edge": "Chromium Safe Storage", + "opera": "Chromium Safe Storage", +} + + +def get_key(browser): + try: + import secretstorage + except ImportError: + return None + label = BROWSER_KEYRING_LABEL.get(browser, "Chromium Safe Storage") + bus = secretstorage.dbus_init() + col = secretstorage.get_default_collection(bus) + if col.is_locked(): + col.unlock() + for item in col.get_all_items(): + if item.get_label() == label: + return item.get_secret() + return None + + +def decrypt_cookie(key, encrypted_value, hash_prefix=True): + from Cryptodome.Cipher import AES + from Cryptodome.Util.Padding import unpad + import hashlib + if not encrypted_value or len(encrypted_value) < 4: + return "" + version = encrypted_value[:3] + ciphertext = encrypted_value[3:] + if version == b"v11": + derived = hashlib.pbkdf2_hmac("sha1", key, b"saltysalt", 1, dklen=16) + elif version == b"v10": + derived = hashlib.pbkdf2_hmac("sha1", b"peanuts", b"saltysalt", 1, dklen=16) + else: + try: + return encrypted_value.decode("utf-8") + except Exception: + return "" + iv = b" " * 16 + try: + cipher = AES.new(derived, AES.MODE_CBC, iv) + decrypted = unpad(cipher.decrypt(ciphertext), AES.block_size) + if hash_prefix: + decrypted = decrypted[32:] + return decrypted.decode("utf-8") + except Exception: + return "" + + +def extract_cookies(browser, output_file=None): + db_rel = BROWSER_DB_PATHS.get(browser) + if db_rel is None: + return False + db_path = Path.home() / ".config" / db_rel + if not db_path.exists(): + return False + key = get_key(browser) + if key is None: + return False + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + tmp_path = tmp.name + shutil.copy2(db_path, tmp_path) + try: + conn = sqlite3.connect(tmp_path) + cur = conn.cursor() + try: + meta_ver = int(cur.execute("SELECT value FROM meta WHERE key = 'version'").fetchone()[0]) + except Exception: + meta_ver = 0 + cur.execute("SELECT host_key, name, path, is_secure, expires_utc, is_httponly, encrypted_value FROM cookies") + rows = cur.fetchall() + conn.close() + finally: + os.unlink(tmp_path) + out = open(output_file, "w") if output_file else sys.stdout + count = 0 + # Auth cookies that need to be shared with youtube.com + auth_cookie_names = { + "SAPISID", "APISID", "SSID", "SID", "HSID", "SIDCC", + "__Secure-1PAPISID", "__Secure-1PSID", "__Secure-1PSIDCC", "__Secure-1PSIDTS", + "__Secure-3PAPISID", "__Secure-3PSID", "__Secure-3PSIDCC", "__Secure-3PSIDTS", + } + google_auth_cookies = {} + try: + out.write("# Netscape HTTP Cookie File\n") + for host, name, path, secure, expires, httponly, enc_val in rows: + value = decrypt_cookie(key, enc_val, hash_prefix=(meta_ver >= 24)) + if not value: + continue + domain_flag = "TRUE" if host.startswith(".") else "FALSE" + secure_str = "TRUE" if secure else "FALSE" + if expires: + unix_ts = int((expires / 1_000_000) - 11644473600) + if unix_ts < 0: + unix_ts = 0 + else: + unix_ts = 0 + out.write(f"{host}\t{domain_flag}\t{path}\t{secure_str}\t{unix_ts}\t{name}\t{value}\n") + count += 1 + # Save auth cookies for cross-domain sharing + if ("google.com" in host or "youtube.com" in host) and name in auth_cookie_names: + google_auth_cookies[name] = (path, secure_str, unix_ts, value) + # Copy auth cookies to youtube.com + for name, (path, secure_str, unix_ts, value) in google_auth_cookies.items(): + out.write(f".youtube.com\tTRUE\t{path}\t{secure_str}\t{unix_ts}\t{name}\t{value}\n") + count += 1 + + # Create legacy aliases from __Secure-3P* cookies + secure_to_legacy = { + "__Secure-3PAPISID": "SAPISID", + "__Secure-3PSID": "SID", + "__Secure-3PSIDCC": "SIDCC", + } + for secure_name, legacy_name in secure_to_legacy.items(): + if secure_name in google_auth_cookies: + path, secure_str, unix_ts, value = google_auth_cookies[secure_name] + out.write(f".youtube.com\tTRUE\t{path}\t{secure_str}\t{unix_ts}\t{legacy_name}\t{value}\n") + count += 1 + out.write(f".google.com\tTRUE\t{path}\t{secure_str}\t{unix_ts}\t{legacy_name}\t{value}\n") + count += 1 + finally: + if output_file: + out.close() + print(f"Extracted {count} cookies from {browser}", file=sys.stderr) + return True + + +def main(): + if len(sys.argv) < 2: + print("Usage: extract-browser-cookies.py BROWSER [OUTPUT_FILE]", file=sys.stderr) + sys.exit(1) + browser = sys.argv[1] + output = sys.argv[2] if len(sys.argv) > 2 else None + sys.exit(0 if extract_cookies(browser, output) else 1) + + +if __name__ == "__main__": + main()