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
-
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()