#!/usr/bin/perl5.34.3 # Copyright (C) 2010-2025 Trizen . # # This program is free software; you can redistribute it and/or modify it # under the terms of either: the GNU General Public License as published # by the Free Software Foundation; or the Artistic License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # # See https://dev.perl.org/licenses/ for more information. # #------------------------------------------------------- # pipe-viewer # Fork: 30 October 2020 # Edit: 29 March 2025 # https://github.com/trizen/pipe-viewer #------------------------------------------------------- # pipe-viewer is a command line utility for streaming YouTube videos in mpv/vlc. # This is a fork of straw-viewer: # https://github.com/trizen/straw-viewer =encoding utf8 =head1 NAME pipe-viewer - YouTube from command line. pipe-viewer --help pipe-viewer --tricks pipe-viewer --examples pipe-viewer --stdin-help =cut use utf8; use 5.016; use warnings; no warnings 'once'; use Term::ReadLine qw(); use File::Spec::Functions qw( catdir catfile curdir path rel2abs splitdir file_name_is_absolute ); my $DEVEL; BEGIN { $DEVEL = -w __FILE__ } sub devel_path { require FindBin; my @dirs = splitdir($FindBin::RealBin); pop(@dirs); return @dirs; } use if $DEVEL, lib => $DEVEL && catdir(devel_path(), 'lib'); use WWW::PipeViewer v0.5.5; use WWW::PipeViewer::ParseJSON; use WWW::PipeViewer::RegularExpressions; binmode(STDOUT, ':utf8'); my $appname = 'CLI Pipe Viewer'; my $version = $WWW::PipeViewer::VERSION; my $execname = 'pipe-viewer'; # A better support: my $term = Term::ReadLine->new("$appname $version"); # Options (key=>value) goes here my %opt; my $term_width = 80; # Keep track of watched videos by their ID my %WATCHED_VIDEOS; # Unchangeable data goes here my %constant = ( win32 => (lc($^O) eq 'mswin32' ? 1 : 0), android => (lc($^O) eq 'android' ? 1 : 0), ); my $home_dir; my $xdg_config_home = $ENV{XDG_CONFIG_HOME}; if ($xdg_config_home and -d $xdg_config_home) { require File::Basename; $home_dir = File::Basename::dirname($xdg_config_home); if (not -d $home_dir) { $home_dir = $ENV{HOME} || curdir(); } } else { $home_dir = $ENV{HOME} || $ENV{LOGDIR} || ($constant{win32} ? '\Local Settings\Application Data' : ((getpwuid($<))[7] || `echo -n ~`)); if (not -d $home_dir) { $home_dir = curdir(); } $xdg_config_home = catdir($home_dir, '.config'); } # Configuration dirs my $config_dir = catdir($xdg_config_home, $execname); my $local_playlists_dir = catdir($config_dir, 'playlists'); # Configuration files my $config_file = catfile($config_dir, "$execname.conf"); my $saved_channels_file = catfile($config_dir, 'users.txt'); my $subscribed_channels_file = catfile($config_dir, 'subscribed_channels.txt'); my $history_file = catfile($config_dir, 'cli-history.txt'); my $watched_file = catfile($config_dir, 'watched.txt'); # Special local playlists my $watch_history_data_file = catfile($local_playlists_dir, 'watched_videos.dat'); my $liked_videos_data_file = catfile($local_playlists_dir, 'liked_videos.dat'); my $disliked_videos_data_file = catfile($local_playlists_dir, 'disliked_videos.dat'); my $favorite_videos_data_file = catfile($local_playlists_dir, 'favorite_videos.dat'); my $subscription_videos_data_file = catfile($local_playlists_dir, "subscriptions.dat"); # Create the config and playlist dirs foreach my $dir ($config_dir, $local_playlists_dir) { if (not -d $dir) { require File::Path; eval { File::Path::make_path($dir) } or warn "[!] Can't create dir <<$dir>>: $!"; } } # Create the special playlist files foreach my $file ($watch_history_data_file, $liked_videos_data_file, $disliked_videos_data_file, $favorite_videos_data_file,) { if (not -s $file) { require Storable; Storable::store([], $file); } } sub which_command { my ($cmd) = @_; if (file_name_is_absolute($cmd)) { return $cmd; } state $paths = [path()]; foreach my $path (@{$paths}) { my $cmd_path = catfile($path, $cmd); if (-f -x $cmd_path) { return $cmd_path; } } return; } # Main configuration my %CONFIG = ( video_players => { ( $constant{android} ? ( vlc => { arg => "start -n org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity -a android.intent.action.VIEW -d *VIDEO*", cmd => "am", fs => "", novideo => "", srt => "", } ) : ( vlc => { cmd => q{vlc}, srt => q{--sub-file=*SUB*}, audio => q{--input-slave=*AUDIO*}, fs => q{--fullscreen}, arg => q{--quiet --play-and-exit --no-video-title-show --input-title-format=*TITLE* *VIDEO*}, novideo => q{--intf=dummy --novideo}, } ) ), mpv => { cmd => q{mpv}, srt => q{--sub-file=*SUB*}, audio => q{--audio-file=*AUDIO*}, fs => q{--fullscreen}, arg => q{--really-quiet --force-media-title=*TITLE* --no-ytdl *VIDEO*}, novideo => q{--no-video}, }, mpvraw => { cmd => q{mpv}, arg => q{--ytdl-raw-options-append="format-sort=ext,res:*RESOLUTION*" *URL*}, fs => q{--fullscreen}, novideo => q{--no-video}, srt => q{--sub-file=*SUB*}, }, mplayer => { cmd => q{mplayer}, srt => q{-sub *SUB*}, audio => q{-audiofile *AUDIO*}, fs => q{-fs}, arg => q{-prefer-ipv4 -really-quiet -title *TITLE* *VIDEO*}, novideo => q{-novideo}, }, }, video_player_selected => ( ($constant{win32} || $constant{android}) ? 'vlc' : undef # auto-defined ), split_videos => $constant{android} ^ 1, # YouTube options dash => 1, # may load slow maxResults => 20, hfr => 1, # true to prefer high frame rate (HFR) videos resolution => 'best', audio_quality => 'best', videoDuration => undef, features => [], order => undef, date => undef, region => undef, # Comments order comments_order => 'top', # valid values: top, new # URI options youtube_video_url => 'https://www.youtube.com/watch?v=%s', youtube_playlist_url => 'https://www.youtube.com/playlist?list=%s', youtube_channel_url => 'https://www.youtube.com/channel/%s', # Subtitle options srt_languages => ['en', 'es'], get_captions => 1, auto_captions => 0, copy_caption => 0, cache_dir => undef, # API api_host => "auto", # Misc options autoplay_mode => 0, http_proxy => undef, cookie_file => undef, user_agent => undef, timeout => undef, env_proxy => 1, confirm => 0, debug => 0, page => 1, colors => $constant{win32} ^ 1, skip_if_exists => 1, prefer_mp4 => 0, prefer_m4a => 0, prefer_av1 => 0, ignore_av1 => 0, prefer_invidious => 0, force_fallback => 0, fat32safe => $constant{win32}, fullscreen => 0, show_video_info => 1, interactive => 1, get_term_width => $constant{win32} ^ 1, download_with_wget => undef, # auto-defined download_with_ytdl => undef, # auto-defined thousand_separator => q{,}, downloads_dir => curdir(), download_and_play => 0, remove_played_file => 0, download_in_subdir => 0, # true to download in a subfolder download_in_subdir_format => '*AUTHOR*', bypass_age_gate_native => 0, bypass_age_gate_with_proxy => 0, ignored_projections => [], # Parallel options get_subscriptions_in_parallel => 0, # Conversion options convert_cmd => 'ffmpeg -i *IN* *OUT*', convert_to => undef, keep_original_video => 0, # Search history history => undef, # auto-defined history_limit => 100_000, history_file => $history_file, # Watch history watch_history => 1, watch_history_file => $watched_file, # Subscribed channels subscription_results => 'uploads', # valid values: uploads, streams, shorts (comma-separated) subscriptions_limit => 10_000, subscriptions_lifetime => 600, saved_channels_file => $saved_channels_file, subscribed_channels_file => $subscribed_channels_file, local_playlist_limit => -1, # Options for watched videos highlight_watched => 1, highlight_color => 'bold', skip_watched => 0, # yt-dlp / youtube-dl support ytdl => 1, ytdl_cmd => undef, # auto-defined # yt-dlp comment options ytdlp_comments => 1, ytdlp_max_comments => 10, ytdlp_max_replies => 3, # Custom layout custom_layout_format => [{width => 3, align => "right", color => "bold", text => "*NO*.",}, {width => "55%", align => "left", color => "bold blue", text => "*TITLE*",}, {width => "15%", align => "left", color => "magenta", text => "*AUTHOR*",}, {width => 3, align => "right", color => "green", text => "*AGE_SHORT*",}, {width => 5, align => "right", color => "green", text => "*VIEWS_SHORT*",}, {width => 8, align => "right", color => "blue", text => "*TIME*",}, ], custom_channel_layout_format => [{width => 3, align => "right", color => "bold", text => "*NO*.",}, {width => "55%", align => "left", color => "bold blue", text => "*AUTHOR*",}, {width => 14, align => "right", color => "green", text => "*VIDEOS* videos",}, {width => 10, align => "right", color => "green", text => "*SUBS_SHORT* subs",}, ], custom_playlist_layout_format => [{align => "right", color => "bold", text => "*NO*.", width => 3}, {align => "left", color => "bold blue", text => "*TITLE*", width => "55%"}, {align => "right", color => "green", text => "*ITEMS* videos", width => 14}, {align => "left", color => "magenta", text => "*AUTHOR*", width => "20%"}, ], ffmpeg_cmd => 'ffmpeg', wget_cmd => 'wget', merge_into_mkv => undef, # auto-defined later merge_into_mkv_args => '-loglevel warning -c:s srt -c:v copy -c:a copy -disposition:s forced', merge_with_captions => 1, set_mtime => $constant{win32} ^ 1, video_filename_format => '*TITLE* - *ID*.*FORMAT*', ); local $SIG{__WARN__} = sub { warn @_; ++$opt{_error} }; my %PLAYER_ARGS; # will store video player arguments my $base_options = <<'BASE'; # Base [keywords] : search for YouTube videos [youtube-url] : play a video by YouTube URL :v(ideoid)=ID : play videos by YouTube video IDs [playlist-url] : display videos from a playlistURL :playlist=ID : display videos from a playlistID BASE my $control_options = <<'CONTROL'; # Control :n(ext) : display the next page of results :r(eturn) : return to the previous page of results CONTROL my $other_options = <<'OTHER'; # Others :refresh : refresh the current list of results :dv=i : display the data structure of result i -argv -argv2=v : apply some arguments (e.g.: -u=google) :q, :quit, :exit : close the application OTHER my $notes_options = <<'NOTES'; NOTES: 1. You can specify more options in a row, separated by spaces. 2. A stdin option is valid only if it begins with '=', ';' or ':'. 3. Quoting a group of space separated keywords or option-values, the group will be considered a single keyword or a single value. NOTES my $general_help = <<"HELP"; $control_options $other_options $notes_options Examples: 3 : select the 3rd result -sv funny cats : search for videos -sc mathematics : search for channels -sp classical music : search for playlists HELP my $playlists_help = <<"PLAYLISTS_HELP" . $general_help; # Select a playlist : list videos from the selected playlist :p=i : list playlists from the selected author :pp=i,i : play videos from the selected playlists PLAYLISTS_HELP my $channels_help = <<"CHANNELS_HELP" . $general_help; # Select a channel : latest uploads from channel :streams=i :us=i : latest streams from channel :shorts=i : latest shorts from channel :ps=i : popular streams from channel :pv=i :popular=i : popular uploads from channel :p=i :playlists=i : playlists from channel # Save and remove channels :save=i : save channel :s=i :subscribe=i : subscribe to the channel :unsub=i : unsubscribe from the channel :r=i :remove=i : remove the channel CHANNELS_HELP my $comments_help = <<"COMMENTS_HELP" . $general_help; # Comments COMMENTS_HELP my $complete_help = <<"STDIN_HELP"; $base_options $control_options # YouTube :i(nfo)=i,i : display more information :d(ownload)=i,i : download the selected videos :c(omments)=i : display video comments :r(elated)=i : display related videos :u(ploads)=i : display author's latest uploads :streams=i :us=i : display author's latest streams :shorts=i : display author's latest shorts :pv=i :popular=i : display author's popular uploads :ps=i : display author's popular streams :p(laylists)=i : display author's playlists :subscribe=i : subscribe to author's channel :w=i :mark=i : add video to watched history :(dis)like=i : like or dislike a video :fav(orite)=i : favorite a video :autoplay=i : autoplay mode, starting from video i # Playing : play the corresponding video 3-8, 3..8 : same as 3 4 5 6 7 8 8-3, 8..3 : same as 8 7 6 5 4 3 8 2 12 4 6 5 1 : play the videos in a specific order 10.. : play all the videos onwards from 10 :q(ueue)=i,i,... : enqueue videos for playing them later :pq, :play-queue : play the enqueued videos (if any) :anp, :nnp : auto-next-page, no-next-page :play=i,i,... : play a group of selected videos :regex='RE' : play videos matched by a regex (/i) :kregex=KEY,RE : play videos if the value of KEY matches the RE $other_options $notes_options ** Examples: :regex='\\w \\d' -> play videos matched by a regular expression. :info=1 -> show extra information for the first video. :d18-20,1,2 -> download the selected videos: 18, 19, 20, 1 and 2. 3 4 :next 9 -> play the 3rd and 4th videos from the current page, go to the next page and play the 9th video. STDIN_HELP { my $config_documentation = <<"EOD"; #!/usr/bin/perl # $appname $version - configuration file use utf8; EOD sub dump_configuration { my ($config_file) = @_; require Data::Dump; open my $config_fh, '>', $config_file or do { warn "[!] Can't open '${config_file}' for write: $!"; return }; my $dumped_config = q{our $CONFIG = } . Data::Dump::pp(\%CONFIG) . "\n"; if (defined($ENV{HOME}) and $home_dir eq $ENV{HOME}) { $dumped_config =~ s/\Q$home_dir\E/\$ENV{HOME}/g; } print $config_fh $config_documentation, $dumped_config; close $config_fh; } } our $CONFIG; our @FEATURES; sub toggle_features { my ($enabled, @list) = @_; if ($enabled) { @FEATURES = sort(do { my %seen; grep { !$seen{$_}++ } (@FEATURES, @list); } ); } else { my %enabled; @enabled{@FEATURES} = (); foreach my $feature (@list) { delete($enabled{$feature}); } @FEATURES = sort(keys %enabled); } } sub load_config { my ($config_file) = @_; if (not -e $config_file or -z _ or $opt{reconfigure}) { dump_configuration($config_file); } require $config_file; # Load the configuration file if (ref $CONFIG ne 'HASH') { die "[ERROR] Invalid configuration file!\n\t\$CONFIG is not an HASH ref!"; } my $update_config = 0; # Rename `watched_file` to `watch_history_file` if (exists $CONFIG->{watched_file}) { $CONFIG->{watch_history_file} = delete $CONFIG->{watched_file}; $update_config = 1; } # Rename `youtube_users_file` to `saved_channels_file` if (exists $CONFIG->{youtube_users_file}) { $CONFIG->{saved_channels_file} = delete $CONFIG->{youtube_users_file}; $update_config = 1; } toggle_features(1, @{$CONFIG->{features} // []}); do { my @feature_options = qw( videoCaption 1 subtitles videoCaption true subtitles videoDefinition high hd videoDimension 3d 3d videoLicense creative_commons creative_commons ); while (scalar @feature_options) { my ($option_name, $option_value, $feature) = splice(@feature_options, 0, 3); if (($CONFIG->{$option_name} // '') eq $option_value) { toggle_features(1, $feature); } } }; $CONFIG->{features} = \@FEATURES; # Get valid config keys my @valid_keys = grep { exists $CONFIG{$_} } keys %{$CONFIG}; @CONFIG{@valid_keys} = @{$CONFIG}{@valid_keys}; # Define the cache directory if (not defined $CONFIG{cache_dir}) { my $cache_dir = ($ENV{XDG_CACHE_HOME} and -d $ENV{XDG_CACHE_HOME}) ? $ENV{XDG_CACHE_HOME} : catdir($home_dir, '.cache'); if (not -d $cache_dir) { $cache_dir = catdir(curdir(), '.cache'); } $CONFIG{cache_dir} = catdir($cache_dir, 'pipe-viewer'); $update_config = 1; } # Locate video player if (not $CONFIG{video_player_selected}) { foreach my $key (sort keys %{$CONFIG{video_players}}) { if (defined(my $abs_player_path = which_command($CONFIG{video_players}{$key}{cmd}))) { $CONFIG{video_players}{$key}{cmd} = $abs_player_path; $CONFIG{video_player_selected} = $key; $update_config = 1; last; } } if (not $CONFIG{video_player_selected}) { warn "\n[!] Please install a supported video player! (e.g.: mpv)\n\n"; $CONFIG{video_player_selected} = 'mpv'; } } # Locate yt-dlp and youtube-dl if ( not defined($CONFIG{ytdl_cmd}) or not defined($CONFIG{download_with_ytdl})) { foreach my $ytdl (qw(yt-dlp youtube-dl)) { my $ytdl_path = which_command($ytdl); if (defined($ytdl_path)) { $CONFIG{ytdl_cmd} //= $ytdl_path; $CONFIG{download_with_ytdl} //= 1; last; } } $CONFIG{ytdl_cmd} //= 'yt-dlp'; $CONFIG{download_with_ytdl} //= 0; $update_config = 1; } # Download with wget if it is installed if (not defined $CONFIG{download_with_wget}) { my $wget_path = which_command('wget'); if (defined($wget_path)) { $CONFIG{wget_cmd} = $wget_path; $CONFIG{download_with_wget} = 1; } else { $CONFIG{download_with_wget} = 0; } $update_config = 1; } # Merge into MKV if ffmpeg is installed if (not defined $CONFIG{merge_into_mkv}) { my $ffmpeg_path = which_command('ffmpeg'); if (defined($ffmpeg_path)) { $CONFIG{ffmpeg_cmd} = $ffmpeg_path; $CONFIG{merge_into_mkv} = 1; } else { $CONFIG{merge_into_mkv} = 0; } $update_config = 1; } # Enable history if Term::ReadLine::Gnu::XS is installed if (not defined $CONFIG{history}) { if (eval { $term->can('ReadHistory') }) { $CONFIG{history} = 1; } else { $CONFIG{history} = 0; } $update_config = 1; } foreach my $key (keys %CONFIG) { if (not exists $CONFIG->{$key}) { $update_config = 1; last; } } dump_configuration($config_file) if $update_config; # Create the cache directory (if needed) foreach my $path ($CONFIG{cache_dir}) { next if -d $path; require File::Path; eval { File::Path::make_path($path) } or warn "[!] Can't create path <<$path>>: $!"; } @opt{keys %CONFIG} = values(%CONFIG); } load_config($config_file); if ($opt{watch_history}) { if (-f $opt{watch_history_file}) { if (open my $fh, '<', $opt{watch_history_file}) { chomp(my @ids = <$fh>); @WATCHED_VIDEOS{@ids} = (); close $fh; } else { warn "[!] Can't open the watched file `$opt{watch_history_file}' for reading: $!"; } } } if ($opt{history}) { # Create the history file. if (not -e $opt{history_file}) { require File::Basename; my $dir = File::Basename::dirname($opt{history_file}); if (not -d $dir) { require File::Path; eval { File::Path::make_path($dir) } or warn "[!] Can't create path <<$dir>>: $!"; } open my $fh, '>', $opt{history_file} or warn "[!] Can't create the history file `$opt{history_file}': $!"; } # Add history to Term::ReadLine eval { $term->ReadHistory($opt{history_file}) }; # All history entries my @history; eval { @history = $term->history_list }; # Rewrite the history file, when the history_limit has been reached. if ($opt{history_limit} > 0 and @history > $opt{history_limit}) { # Try to create a backup, first require File::Copy; File::Copy::cp($opt{history_file}, "$opt{history_file}.bak"); if (open my $fh, '>', $opt{history_file}) { # Keep only the most recent half part of the history file say {$fh} join("\n", @history[($opt{history_limit} >> 1) .. $#history]); close $fh; } } } my $yv_obj = WWW::PipeViewer->new( escape_utf8 => 1, config_dir => $config_dir, ytdl => $opt{ytdl}, ytdl_cmd => $opt{ytdl_cmd}, cache_dir => $opt{cache_dir}, env_proxy => $opt{env_proxy}, cookie_file => $opt{cookie_file}, http_proxy => $opt{http_proxy}, user_agent => $opt{user_agent}, timeout => $opt{timeout}, ); require WWW::PipeViewer::Utils; my $yv_utils = WWW::PipeViewer::Utils->new( youtube_video_url_format => $opt{youtube_video_url}, youtube_channel_url_format => $opt{youtube_channel_url}, youtube_playlist_url_format => $opt{youtube_playlist_url}, thousand_separator => $opt{thousand_separator}, ); { # Apply the configuration file my %temp = %CONFIG; apply_configuration(\%temp); } #---------------------- PIPE-VIEWER USAGE ----------------------# sub help { my $eqs = q{=} x 30; local $" = ', '; print <<"HELP"; \n $eqs \U$appname\E $eqs usage: $execname [options] ([url] | [keywords]) == Base == [URL] : play an YouTube video by URL [keywords] : search for YouTube videos [playlist URL] : display a playlist of YouTube videos == YouTube Options == * Categories -c --categories : display the available YouTube categories * Region --region=s : set the region code (default: US) * Videos -uv --uploads=s : list videos uploaded by a specific channel or user -us --streams=s : list livestream videos by a specific channel or user --shorts=s : list short videos by a specific channel or user -pv --popular=s : list the most popular videos from a specific channel -ps --pstreams=s : list the most popular streams from a specific channel --pshorts=s : list the most popular shorts from a specific channel -id --videoids=s,s : play YouTube videos by their IDs -rv --related=s : show related videos for a video ID or URL -sv --search-videos : search for YouTube videos (default mode) -wv --watched-videos : list the most recent watched videos -ls --local-subs : display subscription videos from local channels * Playlists -up --playlists=s : list playlists created by a specific channel or user -lp --local-playlists : display the list of local playlists -lp=s : display a local playlist by name -sp --search-pl : search for playlists of videos --pid=s : list a playlist of videos by playlist ID --pp=s,s : play the videos from the given playlist IDs * Trending --trending:s : show trending videos in a given category valid categories: music gaming news movies popular * Channels -sc --channels : search for YouTube channels * Movies -sm --movies : search for YouTube movies * Comments --comments=s : display comments for a video by ID or URL --comments-order=s : change the order of YouTube comments valid values: relevance, time --ytdlp-comments! : use `yt-dlp` to extract comments --max-comments=i : maximum number of comments (with --ytdlp-comments) --max-replies=i : maximum number of replies per thread * Filtering --author=s : search in videos uploaded by a specific user --duration=s : filter search results based on video length valid values: short long --captions! : only videos with or without closed captions --order=s : order the results using a specific sorting method valid values: relevance rating upload_date view_count --time=s : short videos published in a time period valid values: hour today week month year --360! : search only for 360° videos --hdr! : search only for HDR videos --live! : search only for live videos --vr180! : search only for VR180 videos --vd=s : set the video definition (any, hd, or 4k) --4k : shortcut for --video-definition=hd --hd : shortcut for --video-definition=4k --dimension=s : set video dimension (any or 3d) --license=s : set video license (any or creative_commons) --page=i : get results starting with a specific page number --results=i : how many results to display per page (max: 50) --hfr! : prefer high frame rate (HFR) videos -2 -3 -4 -7 -1 : resolutions: 240p, 360p, 480p, 720p and 1080p -a --audio : prefer audio part only (implied by --novideo) --best : prefer best resolution available --resolution=s : supported resolutions: best, 2160p, 1440p, 1080p, 720p, 480p, 360p, 240p, 144p, audio. * Display local -P --playlists : show the local playlists -F --favorites : show the local favorite videos -lc --saved-channels : show the saved channels -S --subscriptions : show the subscribed channels -L --likes : show the videos that you liked -D --dislikes : show the videos that you disliked * Save local --save=s : save a given channel ID or username in -lc --subscribe=s : subscribe to a given channel ID or username --favorite=s : favorite a video by URL or ID --like=s : like a video by URL or ID (see: -L) --dislike=s : dislike a video by URL or ID (see: -D) == Player Options == * Arguments -f --fullscreen! : play videos in fullscreen mode -n --novideo : play audio only, without displaying video --append-arg=s : append some command-line parameters to the media player --player=s : select a player to stream videos available players: @{[keys %{$CONFIG->{video_players}}]} == Download Options == * Download -d --download! : activate the download mode -dp --dl-play! : play the video after download (with -d) -rp --rem-played! : delete a local video after played (with -dp) --dl-in-subdir! : download videos in subdirectories (with -d) --wget-dl! : download videos with wget --skip-if-exists! : don't download videos which already exist (with -d) --copy-caption! : copy and rename the caption for downloaded videos --downloads-dir=s : downloads directory (set: '$opt{downloads_dir}') --filename=s : set a custom format for the video filename (see: -T) --fat32safe! : makes filenames FAT32 safe --mkv-merge! : merge audio and video into an MKV container --merge-captions! : include closed-captions in the MKV container --set-mtime! : set the original file modification time * Convert --convert-cmd=s : command for converting videos after download which include the *IN* and *OUT* tokens --convert-to=s : convert video to a specific format (with -d) --keep-original! : keep the original video after converting == Other Options == * Behavior -A --all! : play the video results in order -B --backwards! : play the video results in reverse order -s --shuffle! : shuffle the results of videos -I --interactive! : interactive mode, prompting for user input --autoplay! : autoplay mode, automatically playing related videos --std-input=s : use this value as the first standard input --max-seconds=i : ignore videos longer than i seconds --min-seconds=i : ignore videos shorter than i seconds --get-term-width! : allow $execname to read your terminal width --skip-watched! : don't play already watched videos --highlight! : remember and highlight selected videos --confirm! : show a confirmation message after each play --prefer-mp4! : prefer videos in MP4 format, instead of VP9 --prefer-av1! : prefer videos in AV1 format, instead of VP9 --prefer-m4a! : prefer audios in AAC format, instead of OPUS --ignore-av1! : ignore videos in AV1 format --audio-quality=s : audio quality: best, medium or low --force-fallback! : extract streaming URLs using the fallback method * Closed-captions --get-captions! : download closed-captions for videos --auto-captions! : include or exclude auto-generated captions --srt-languages=s : comma separated list of preferred languages * Config --config=s : configuration file --update-config! : update the configuration file * Output -i --info=s : show information for a video ID or URL -e --extract=s : extract information from videos (see: -T) --extract-file=s : extract information from videos in this file --dump=format : dump metadata information in `videoID.format` files valid formats: json, perl -q --quiet : do not display any warning --really-quiet : do not display any warning or output --video-info! : show video information before playing --escape-info! : quotemeta() the fields of the `--extract` --use-colors! : enable or disable the ANSI colors for text * Formatting --custom-layout=s : custom layout format for videos --custom-channel-layout=s : custom layout format for channels --custom-playlist-layout=s : custom layout format for playlists * Other --invidious! : prefer invidious instances over parsing YouTube --api=s : set an API host from https://api.invidious.io/ --api=auto : use a random instance of invidious --cookies=s : file to read cookies from and dump cookie --user-agent=s : specify a custom user agent --proxy=s : set HTTP(S)/SOCKS proxy: 'proto://domain.tld:port/' If authentication is required, use 'proto://user:pass\@domain.tld:port/' --split-videos! : include or exclude the itags for split videos --dash! : include or exclude segmented DASH videos --ytdl! : use youtube-dl for videos with encrypted signatures `--no-ytdl` will use invidious instances --ytdl-cmd=s : yt-dlp / youtube-dl command (default: $CONFIG{ytdl_cmd}) Help options: -T --tricks : show more 'hidden' features of $execname -E --examples : show several usage examples of $execname -H --stdin-help : show the valid stdin options for $execname -v --version : print version and exit -h --help : print help and exit --debug:1..3 : see behind the scenes NOTES: ! -> the argument can be negated with '--no-' =i -> requires an integer argument =s -> requires an argument :s -> can take an optional argument =s,s -> can take more arguments separated by commas HELP main_quit(0); } sub wrap_text { my (%args) = @_; require Text::Wrap; local $Text::Wrap::columns = ($args{columns} || $term_width) - 8; my $text = "@{$args{text}}"; $text =~ tr{\r}{}d; return eval { Text::Wrap::wrap($args{i_tab}, $args{s_tab}, $text) } // $text; } sub tricks { print <<"TRICKS"; == pipe-viewer -- tips and tricks == -> Playing videos > To stream the videos in other players, you need to change the configuration file. Where it says "video_player_selected", change it to any player which is defined inside the "video_players" hash. -> Arguments > Almost all boolean arguments can be negated with a "--no-" prefix. > Arguments that require an ID/URL, you can specify more than one, separated by whitespace (quoted), or separated by commas. -> More STDIN help: > ":r", ":return" will return to the previous page of results. For example, if you search for playlists, then select a playlist of videos, inserting ":r" will return back to the playlist results. > "6" (quoted) will search for videos with the keyword '6'. > If a stdin option is followed by one or more digits, the equal sign, which separates the option from value, can be omitted. Example: :i2,4 is equivalent with :i=2,4 :d1-5 is equivalent with :d=1,2,3,4,5 :c10 is equivalent with :c=10 > When more videos are selected to play, you can stop them by pressing CTRL+C. $execname will return to the previous section. > Space inside the values of STDIN options, can be either quoted or backslashed. Example: :re=video\\ title == :re="video title" > ":anp" stands for "Auto Next Page". How do we use it? Well, let's search for some videos. Now, if we want to play only the videos matched by a regex, we'd say :re="REGEX". But, what if we want to play the videos from the next pages too? In this case, ":anp" is your friend. Use it wisely! -> Special tokens: *ID* : the YouTube video ID *AUTHOR* : the author name of the video *CHANNELID* : the channel ID of the video *RESOLUTION* : the resolution of the video *VIEWS* : the number of views *VIEWS_SHORT* : the number of views in abbreviated notation *VIDEOS* : the number of channel videos *VIDEOS_SHORT* : the number of channel videos in abbreviated notation *SUBS* : the number of channel subscriptions *SUBS_SHORT* : the number of channel subscriptions in abbreviated notation *ITEMS* : the number of playlist items *ITEMS_SHORT* : the number of playlist items in abbreviated notation *LIKES* : the number of likes *RATING* : the rating of the video (as a percentage, extrapolated from likes & views) *DURATION* : the duration of the video in seconds *PUBLISHED* : the publication date as "DD MMM YYYY" *AGE* : the age of a video (N days, N months, N years) *AGE_SHORT* : the abbreviated age of a video (Nd, Nm, Ny) *TIME* : the duration of the video as "HH::MM::SS" *TITLE* : the title of the video *FTITLE* : the title of the video (filename safe) *DESCRIPTION* : the description of the video *URL* : the YouTube URL of the video *ITAG* : the itag value of the video *FORMAT* : the extension of the video (without the dot) *SUB* : the local subtitle file (if any) *AUDIO* : the audio URL of the video (only in DASH mode) *VIDEO* : the video URL of the video (it might not contain audio) *AOV* : audio URL (if any) or video URL (in this order) -> Special escapes: \\t tab \\n newline \\r return \\f form feed \\b backspace \\a alarm (bell) \\e escape -> Extracting information from videos: > Extracting information can be achieved by using the "--extract" command-line option which takes a given format as its argument, which is defined by using special tokens, special escapes or literals. Example: $execname --no-interactive --extract '*TITLE* (*ID*)' [URL] -> Configuration file: $config_file -> Donations gladly accepted: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=75FUVBE6Q73T8 TRICKS main_quit(0); } sub examples { print <<"EXAMPLES"; ==== COMMAND LINE EXAMPLES ==== Command: $execname -A -n russian music Results: play all the video results (-A) only audio, no video (-n) search for "russian music" Note: -A will include the videos from the next pages as well. Command: $execname --comments 'https://www.youtube.com/watch?v=U6_8oIPFREY' Results: show video comments for a specific video URL or videoID. Command: $execname --results=5 -up=khanacademy Results: the most recent 5 playlists by a specific author (-up). Command: $execname --author=MIT atom Results: search only in videos by a specific author. Command: $execname -S=vsauce Results: get the subscriptions for a username. Command: $execname cats --order=view_count --duration=short Results: search for 'cats' videos, ordered by ViewCount and short duration. Command: $execname -sc math lessons Results: search for YouTube channels. ==== USER INPUT EXAMPLES ==== A STDIN option can begin with ':', ';' or '='. Command: , :n, :next Results: get the next page of results. Command: :r, :return Results: return to the previous page of results. Command: :i4..6, :i7-9, :i20-4, :i2, :i=4, :info=4 Results: show extra information for the selected videos. Command: :d5,2, :d=3, :download=8 Results: download the selected videos. Command: :c2, :comments=4 Results: show comments for a selected video. Command: :r4, :related=6 Results: show related videos for a selected video. Command: :a14, :author=12 Results: show videos uploaded by the author who uploaded the selected video. Command: :p9, :playlists=14 Results: show playlists created by the author who uploaded the selected video. Command: :subscribe=7 Results: subscribe to the author's channel who uploaded the selected video. Command: :like=2, :dislike=4,5 Results: like or dislike the selected videos. Command: :fav=4, :favorite=3..5 Results: favorite the selected videos. Command: 3, 5..7, 12-1, 9..4, 2 3 9 Results: play the selected videos. Command: :q3,5, :q=4, :queue=3-9 Results: enqueue the selected videos to play them later. Command: :pq, :play-queue Results: play the videos enqueued by the :queue option. Command: :re="^Linux" Results: play videos matched by a regex. Example: matches title: "Linux video" Command: :regex='linux.*part \\d+/\\d+' Example: matches title: "Introduction to Linux (part 1/4)" Command: :anp 1 2 3 Results: play the first three videos from every page. Command: :r, :return Results: return to the previous section. EXAMPLES main_quit(0); } sub stdin_help { print $complete_help; main_quit(0); } # Print version sub version { print "$appname $version\n"; main_quit(0); } sub apply_configuration { my ($opt, $keywords) = @_; if ($yv_obj->get_debug >= 2 or (defined($opt->{debug}) && $opt->{debug} >= 2)) { require Data::Dump; say "=>> Options with keywords: <@{$keywords}>"; Data::Dump::pp($opt); } # ... BASIC OPTIONS ... # if (delete $opt->{quiet}) { close STDERR; } if (delete $opt->{really_quiet}) { close STDERR; close STDOUT; } # ... YOUTUBE OPTIONS ... # foreach my $option_name ( qw( videoDuration maxResults api_host date order channelId region debug http_proxy page comments_order user_agent force_fallback cookie_file timeout ytdl ytdl_cmd prefer_mp4 prefer_av1 prefer_invidious ytdlp_comments ytdlp_max_comments ytdlp_max_replies bypass_age_gate_native bypass_age_gate_with_proxy ) ) { if (defined $opt->{$option_name}) { my $code = \&{"WWW::PipeViewer::set_$option_name"}; my $value = $opt->{$option_name}; # don't delete the value! my $set_value = $yv_obj->$code($value); if (not defined($set_value) or $set_value ne $value) { warn "\n[!] Invalid value <$value> for option <$option_name>\n"; } } } my @feature_options = qw( 360 1 360 hdr 1 hdr live 1 live vr180 1 vr180 videoCaption 1 subtitles videoDimension 3d 3d videoLicense creative_commons creative_commons ); while (scalar @feature_options) { my ($option_name, $option_value, $feature) = splice(@feature_options, 0, 3); if (defined $opt->{$option_name}) { toggle_features($opt->{$option_name} eq $option_value, $feature); } } if (defined $opt->{videoDefinition}) { my $vd = $opt->{videoDefinition}; toggle_features($vd eq 'hd', 'hd'); toggle_features($vd eq '4k', '4k'); } if (scalar @FEATURES) { $yv_obj->set_features(\@FEATURES); } if (defined $opt->{author}) { my $name = delete $opt->{author}; if (my $id = extract_channel_id($name)) { if (not $yv_utils->is_channelID($id)) { $id = $yv_obj->channel_id_from_username($id) // do { warn_invalid("username or channel ID", $id); undef; }; } $yv_obj->set_channelId($id); } else { warn_invalid("username or channel ID", $name); } } # ... OTHER OPTIONS ... # if (defined $opt->{extract_info_file}) { open my $fh, '>:utf8', delete($opt->{extract_info_file}); $opt{extract_info_fh} = $fh; } if (defined $opt->{colors}) { $opt{_colors} = $opt->{colors}; if (delete $opt->{colors}) { require Term::ANSIColor; no warnings 'redefine'; *colored = \&Term::ANSIColor::colored; *colorstrip = \&Term::ANSIColor::colorstrip; } else { no warnings 'redefine'; *colored = sub { $_[0] }; *colorstrip = sub { $_[0] }; } } # ... SUBROUTINE CALLS ... # if (defined $opt->{subscribe}) { foreach my $channel_id (split(/[,\s]+/, delete $opt->{subscribe})) { subscribe_channel($channel_id); } } if (defined $opt->{save_channel}) { foreach my $channel_id (split(/[,\s]+/, delete $opt->{save_channel})) { save_channel($channel_id); } } if (defined $opt->{favorite_video}) { favorite_videos(split(/[,\s]+/, delete $opt->{favorite_video})); } if (defined $opt->{like_video}) { rate_videos('like', split(/[,\s]+/, delete $opt->{like_video})); } if (defined $opt->{dislike_video}) { rate_videos('dislike', split(/[,\s]+/, delete $opt->{dislike_video})); } if (defined $opt->{play_video_ids}) { get_and_play_video_ids(split(/[,\s]+/, delete $opt->{play_video_ids})); } if (defined $opt->{play_playlists}) { get_and_play_playlists(split(/[,\s]+/, delete $opt->{play_playlists})); } if (defined $opt->{saved_channels}) { print_saved_channels(delete $opt->{saved_channels}); } if (defined $opt->{subscribed_channels}) { print_subscribed_channels(delete $opt->{subscribed_channels}); } if (defined $opt->{local_playlist}) { print_local_playlist(delete $opt->{local_playlist}); } if (defined $opt->{playlist_id}) { my $playlistID = get_valid_playlist_id(delete($opt->{playlist_id})) // return; get_and_print_videos_from_playlist($playlistID); } if (delete $opt->{search_videos}) { print_videos($yv_obj->search_videos([@{$keywords}])); } if (delete $opt->{search_channels}) { print_channels($yv_obj->search_channels([@{$keywords}])); } if (delete $opt->{search_playlists}) { print_playlists($yv_obj->search_playlists([@{$keywords}])); } if (delete $opt->{search_movies}) { print_videos($yv_obj->search_movies([@{$keywords}])); } if (delete $opt->{categories}) { print_categories($yv_obj->video_categories); } if (delete $opt->{watched_videos}) { print_watched_videos(); } if (delete $opt->{local_subscriptions}) { print_local_subscription_videos(); } if (defined $opt->{uploads}) { my $str = delete $opt->{uploads}; if ($str) { if (my $id = extract_channel_id($str)) { print_videos($yv_obj->uploads($id)); } else { warn_invalid("username or channel ID", $str); } } else { warn_invalid("username or channel ID", $str); } } if (defined $opt->{streams}) { my $str = delete $opt->{streams}; if ($str) { if (my $id = extract_channel_id($str)) { print_videos($yv_obj->streams($id)); } else { warn_invalid("username or channel ID", $str); } } else { warn_invalid("username or channel ID", $str); } } if (defined $opt->{shorts}) { my $str = delete $opt->{shorts}; if ($str) { if (my $id = extract_channel_id($str)) { print_videos($yv_obj->shorts($id)); } else { warn_invalid("username or channel ID", $str); } } else { warn_invalid("username or channel ID", $str); } } if (defined $opt->{popular_videos}) { my $str = delete $opt->{popular_videos}; if ($str eq '') { print_videos($yv_obj->trending_videos_from_category('popular')); } elsif (my $id = extract_channel_id($str)) { if (not $yv_utils->is_channelID($id)) { $id = $yv_obj->channel_id_from_username($id) // do { warn_invalid("username or channel ID", $id); undef; }; } print_videos($yv_obj->popular_videos($id)); } else { warn_invalid("username or channel ID", $str); } } if (defined $opt->{popular_streams}) { my $str = delete $opt->{popular_streams}; if (my $id = extract_channel_id($str)) { if (not $yv_utils->is_channelID($id)) { $id = $yv_obj->channel_id_from_username($id) // do { warn_invalid("username or channel ID", $id); undef; }; } print_videos($yv_obj->popular_streams($id)); } else { warn_invalid("username or channel ID", $str); } } if (defined $opt->{popular_shorts}) { my $str = delete $opt->{popular_shorts}; if (my $id = extract_channel_id($str)) { if (not $yv_utils->is_channelID($id)) { $id = $yv_obj->channel_id_from_username($id) // do { warn_invalid("username or channel ID", $id); undef; }; } print_videos($yv_obj->popular_shorts($id)); } else { warn_invalid("username or channel ID", $str); } } if (defined $opt->{trending}) { my $cat_id = delete $opt->{trending}; print_videos($yv_obj->trending_videos_from_category($cat_id)); } if (defined $opt->{related_videos}) { get_and_print_related_videos(split(/[,\s]+/, delete($opt->{related_videos}))); } if (defined $opt->{playlists}) { my $str = delete($opt->{playlists}); if ($str) { if (my $id = extract_channel_id($str)) { print_playlists($yv_obj->playlists($id)); } else { warn_invalid("username or channel ID", $str); warn colored("[+] To search for playlists, try: $0 -sp $str", 'bold yellow') . "\n"; } } else { print_local_playlist(); } } if (defined delete $opt->{favorites}) { print_favorite_videos(); } if (defined delete $opt->{likes}) { print_liked_videos(); } if (defined delete $opt->{dislikes}) { print_disliked_videos(); } if (defined $opt->{get_comments}) { get_and_print_comments(split(/[,\s]+/, delete($opt->{get_comments}))); } if (defined $opt->{print_video_info}) { get_and_print_video_info(split(/[,\s]+/, delete $opt->{print_video_info})); } } sub parse_arguments { my ($keywords) = @_; state $x = do { require Getopt::Long; Getopt::Long::Configure('no_ignore_case'); }; my %orig_opt = %opt; my $orig_config_file = "$config_file"; Getopt::Long::GetOptions( # Main options 'help|usage|h|?' => \&help, 'examples|E' => \&examples, 'stdin-help|shelp|sh|H' => \&stdin_help, 'tricks|tips|T' => \&tricks, 'version|v' => \&version, 'config=s' => \$config_file, 'update-config!' => sub { dump_configuration($config_file) }, # Resolutions 'audio|a' => sub { $opt{resolution} = 'audio' }, '144p' => sub { $opt{resolution} = 144 }, '240p|2' => sub { $opt{resolution} = 240 }, '360p|3' => sub { $opt{resolution} = 360 }, '480p|4' => sub { $opt{resolution} = 480 }, '720p|7' => sub { $opt{resolution} = 720 }, '1080p|1' => sub { $opt{resolution} = 1080 }, '1440p' => sub { $opt{resolution} = 1440 }, '2160p' => sub { $opt{resolution} = 2160 }, 'best' => sub { $opt{resolution} = 'best' }, 'hfr!' => \$opt{hfr}, 'res|resolution=s' => \$opt{resolution}, 'comments=s' => \$opt{get_comments}, 'comments-order=s' => \$opt{comments_order}, 'yt-dlp-comments|ytdlp-comments!' => \$opt{ytdlp_comments}, 'max-comments=i' => \$opt{ytdlp_max_comments}, 'max-replies=s' => \$opt{ytdlp_max_replies}, # also supports "all" 'c|categories' => \$opt{categories}, 'video-ids|videoids|id|ids=s' => \$opt{play_video_ids}, 'lc|fc|local-channels|saved-channels:s' => \$opt{saved_channels}, 'subscriptions|sub-channels|S:s' => \$opt{subscribed_channels}, 'lp|local-playlists:s' => \$opt{local_playlist}, 'wv|watched-videos' => \$opt{watched_videos}, 'ls|local-subs|sub-videos|SV' => \$opt{local_subscriptions}, 'subscription-results=s' => \$opt{subscription_results}, #'save-video|save=s' => \$opt{save_video}, 'save|save-channel=s' => \$opt{save_channel}, #'save-playlist=s' => \$opt{save_playlist}, 'search-videos|search|sv!' => \$opt{search_videos}, 'search-channels|channels|sc!' => \$opt{search_channels}, 'search-playlists|sp|p!' => \$opt{search_playlists}, 'search-movie|movies|sm!' => \$opt{search_movies}, 'uploads|U|user|user-videos|uv|u=s' => \$opt{uploads}, 'streams|user-streams|us=s' => \$opt{streams}, 'shorts|user-shorts=s' => \$opt{shorts}, 'favorites|F' => \$opt{favorites}, 'playlists|P|user-playlists|up:s' => \$opt{playlists}, 'likes|L|user-likes' => \$opt{likes}, 'dislikes|D' => \$opt{dislikes}, 'subscribe=s' => \$opt{subscribe}, 'trending|trends:s' => \$opt{trending}, 'playlist-id|pid=s' => \$opt{playlist_id}, # English-UK friendly 'favorite|favourite|favorite-video|favourite-video|fav=s' => \$opt{favorite_video}, 'related-videos|rv=s' => \$opt{related_videos}, 'popular-videos|popular|pv:s' => \$opt{popular_videos}, 'popular-streams|pstreams|ps=s' => \$opt{popular_streams}, 'popular-shorts|pshorts=s' => \$opt{popular_shorts}, 'cookie-file|cookies=s' => \$opt{cookie_file}, 'user-agent|agent=s' => \$opt{user_agent}, 'http-proxy|https-proxy|proxy=s' => \$opt{http_proxy}, 'r|region|region-code=s' => \$opt{region}, 'order|order-by|sort|sort-by=s' => \$opt{order}, 'time|date=s' => \$opt{date}, 'duration=s' => \$opt{videoDuration}, 'max-seconds|max_seconds=i' => \$opt{max_seconds}, 'min-seconds|min_seconds=i' => \$opt{min_seconds}, 'like=s' => \$opt{like_video}, 'dislike=s' => \$opt{dislike_video}, 'author=s' => \$opt{author}, 'all|A|play-all!' => \$opt{play_all}, 'backwards|B!' => \$opt{play_backwards}, 'input|std-input=s' => \$opt{std_input}, 'use-colors|colors|colored!' => \$opt{colors}, 'autoplay!' => \$opt{autoplay_mode}, 'play-playlists|pp=s' => \$opt{play_playlists}, 'debug:1' => \$opt{debug}, 'download|dl|d!' => \$opt{download_video}, 'dimension=s' => \$opt{videoDimension}, 'license=s' => \$opt{videoLicense}, 'vd|video-definition=s' => \$opt{videoDefinition}, 'hd' => sub { $opt{videoDefinition} = 'hd' }, '4k' => sub { $opt{videoDefinition} = '4k' }, '360!' => \$opt{360}, 'hdr!' => \$opt{hdr}, 'live!' => \$opt{live}, 'vr180!' => \$opt{vr180}, 'I|interactive!' => \$opt{interactive}, 'convert-to|convert_to=s' => \$opt{convert_to}, 'keep-original-video!' => \$opt{keep_original_video}, 'e|extract|extract-info=s' => \$opt{extract_info}, 'extract-file=s' => \$opt{extract_info_file}, 'escape-info!' => \$opt{escape_info}, 'dump=s' => sub { my (undef, $format) = @_; $opt{dump} = ( ($format =~ /json/i) ? 'json' : ($format =~ /perl/i) ? 'perl' : do { warn "[!] Invalid format <<$format>> for option --dump\n"; undef; } ); }, # Set a video player 'player|vplayer|video-player|video_player=s' => sub { if (not exists $opt{video_players}{$_[1]}) { die "[!] Unknown video player selected: <<$_[1]>>\n"; } $opt{video_player_selected} = $_[1]; }, 'append-arg|append-args=s' => \$PLAYER_ARGS{user_defined_arguments}, # Others 'captions!' => \$opt{videoCaption}, 'fullscreen|fs|f!' => \$opt{fullscreen}, 'split-videos!' => \$opt{split_videos}, 'confirm!' => \$opt{confirm}, 'prefer-mp4!' => \$opt{prefer_mp4}, 'prefer-av1!' => \$opt{prefer_av1}, 'ignore-av1!' => \$opt{ignore_av1}, 'invidious|prefer-invidious!' => \$opt{prefer_invidious}, 'fallback|force-fallback!' => \$opt{force_fallback}, 'custom-layout-format=s' => \$opt{custom_layout_format}, 'custom-channel-layout-format=s' => \$opt{custom_channel_layout_format}, 'custom-playlist-layout-format=s' => \$opt{custom_playlist_layout_format}, 'merge-into-mkv|mkv-merge!' => \$opt{merge_into_mkv}, 'merge-with-captions|merge-captions!' => \$opt{merge_with_captions}, 'set-mtime|mtime!' => \$opt{set_mtime}, 'api-host|instance=s' => \$opt{api_host}, 'convert-command|convert-cmd=s' => \$opt{convert_cmd}, 'prefer-m4a!' => \$opt{prefer_m4a}, 'audio-quality=s' => \$opt{audio_quality}, 'dash|dash-segmented!' => \$opt{dash}, 'wget-dl|wget-download!' => \$opt{download_with_wget}, 'filename|filename-format=s' => \$opt{video_filename_format}, 'rp|rem-played|remove-played-file!' => \$opt{remove_played_file}, 'info|i=s' => \$opt{print_video_info}, 'get-term-width!' => \$opt{get_term_width}, 'page=i' => \$opt{page}, 'novideo|no-video|n!' => \$opt{novideo}, 'highlight!' => \$opt{highlight_watched}, 'skip-watched!' => \$opt{skip_watched}, 'results=i' => \$opt{maxResults}, 'shuffle|s!' => \$opt{shuffle}, 'pos|position=i' => \$opt{position}, 'ytdl!' => \$opt{ytdl}, 'ytdl-cmd=s' => \$opt{ytdl_cmd}, 'quiet|q!' => \$opt{quiet}, 'really-quiet!' => \$opt{really_quiet}, 'video-info!' => \$opt{show_video_info}, 'dp|downl-play|download-and-play|dl-play!' => \$opt{download_and_play}, 'download-in-subdir|dl-in-subdir!' => \$opt{download_in_subdir}, 'thousand-separator=s' => \$opt{thousand_separator}, 'get-captions|get_captions!' => \$opt{get_captions}, 'auto-captions|auto_captions!' => \$opt{auto_captions}, 'srt-languages=s' => \$opt{srt_languages}, 'copy-caption|copy_caption!' => \$opt{copy_caption}, 'skip-if-exists|skip_if_exists!' => \$opt{skip_if_exists}, 'downloads-dir|download-dir=s' => \$opt{downloads_dir}, 'fat32safe!' => \$opt{fat32safe}, ) or warn "[!] Error in command-line arguments!\n"; if ($config_file ne $orig_config_file) { # load the config file specified with `--config=s` ##say ":: Loading config: $config_file"; $config_file = rel2abs($config_file); my %new_opt = %opt; load_config($config_file); foreach my $key (keys %new_opt) { if ( defined($new_opt{$key}) and defined($orig_opt{$key}) and $new_opt{$key} ne $orig_opt{$key}) { $opt{$key} = $new_opt{$key}; } } } apply_configuration(\%opt, $keywords); } # Parse the arguments if (@ARGV) { require Encode; @ARGV = map { Encode::decode_utf8($_) } @ARGV; parse_arguments(\@ARGV); } for (my $i = 0 ; $i <= $#ARGV ; $i++) { my $arg = $ARGV[$i]; next if (substr($arg, 0, 1) eq q{-}); if (youtube_urls($arg)) { splice(@ARGV, $i--, 1); } } if (my @keywords = grep { substr($_, 0, 1) ne q{-} } @ARGV) { print_videos($yv_obj->search_videos(\@keywords)); } elsif ($opt{interactive} and -t) { first_user_input(); } elsif ($opt{interactive} and -t STDOUT and not -t) { print_videos($yv_obj->search_videos(scalar )); } else { main_quit($opt{_error} || 0); } sub get_valid_video_id { my ($value) = @_; my $id = $value =~ /$get_video_id_re/ ? $+{video_id} : $value =~ /$valid_video_id_re/ ? $value : undef; if (not defined $id) { warn_invalid('videoID', $value); return; } return $id; } sub get_valid_playlist_id { my ($value) = @_; my $id = $value =~ /$get_playlist_id_re/ ? $+{playlist_id} : $value =~ /$valid_playlist_id_re/ ? $value : undef; if (not defined $id) { warn_invalid('playlistID', $value); return; } return $id; } sub extract_channel_id { my ($str) = @_; if ($str =~ /$get_channel_videos_id_re/) { return $+{channel_id}; } if ($str =~ /$get_username_videos_re/) { return $+{username}; } if ($str =~ /$valid_channel_id_re/) { return $+{channel_id}; } if ($str =~ /^[-a-zA-Z0-9_]+\z/) { return $str; } return undef; } sub apply_input_arguments { my ($args, $keywords) = @_; if (@{$args}) { local @ARGV = @{$args}; parse_arguments($keywords); } return 1; } # Get term width sub get_term_width { return $term_width if $constant{win32}; $term_width = (-t STDOUT) ? ((split(q{ }, `stty size`))[1] || $term_width) : $term_width; } sub first_user_input { my @keys = get_input_for_first_time(); state $first_input_help = <<"HELP"; $base_options $other_options $notes_options ** Example: To search for playlists, insert: -p keywords HELP if (scalar(@keys)) { my @for_search; foreach my $key (@keys) { if ($key =~ /$valid_opt_re/) { my $opt = $1; if (general_options(opt => $opt)) { ## ok } elsif ($opt =~ /^(?:h|help)\z/) { print $first_input_help; press_enter_to_continue(); } elsif ($opt =~ /^(?:r|return)\z/) { return; } else { warn_invalid('option', $opt); print "\n"; exit 1; } } elsif (youtube_urls($key)) { ## ok } else { push @for_search, $key; } } if (scalar(@for_search) > 0) { print_videos($yv_obj->search_videos(\@for_search)); } else { __SUB__->(); } } else { __SUB__->(); } } sub get_quotewords { require Text::ParseWords; Text::ParseWords::quotewords(@_); } sub clear_title { my ($title) = @_; $title //= ""; $title =~ s/[^\w\s[:punct:]]//g; $title = join(' ', split(' ', $title)); return $title; } # Straight copy of parse_options() from Term::UI sub _parse_options { my ($input) = @_; my $return = {}; while ( $input =~ s/(?:^|\s+)--?([-\w]+=(["']).+?\2)(?=\Z|\s+)// or $input =~ s/(?:^|\s+)--?([-\w]+=\S+)(?=\Z|\s+)// or $input =~ s/(?:^|\s+)--?([-\w]+)(?=\Z|\s+)//) { my $match = $1; if ($match =~ /^([-\w]+)=(["'])(.+?)\2$/) { $return->{$1} = $3; } elsif ($match =~ /^([-\w]+)=(\S+)$/) { $return->{$1} = $2; } elsif ($match =~ /^no-?([-\w]+)$/i) { $return->{$1} = 0; } elsif ($match =~ /^([-\w]+)$/) { $return->{$1} = 1; } } return wantarray ? ($return, $input) : $return; } sub parse_options2 { my ($input) = @_; warn(colored("\n[!] Input with an odd number of quotes: <$input>", 'bold red') . "\n\n") if $yv_obj->get_debug; my ($args, $keywords) = _parse_options($input); my @args = map { $args->{$_} eq '0' ? "--no-$_" : $args->{$_} eq '1' ? "--$_" : "--$_=$args->{$_}" } keys %{$args}; return wantarray ? (\@args, [split q{ }, $keywords]) : \@args; } sub parse_options { my ($input) = @_; my (@args, @keywords); if (not defined($input) or $input eq q{}) { return \@args, \@keywords; } foreach my $word (get_quotewords(qr/\s+/, 0, $input)) { if (substr($word, 0, 1) eq q{-}) { push @args, $word; } else { push @keywords, $word; } } if (not @args and not @keywords) { return parse_options2($input); } return wantarray ? (\@args, \@keywords) : \@args; } sub get_user_input { my ($text) = @_; if (not $opt{interactive}) { if (not defined $opt{std_input}) { return ':return'; } } my $input = unpack( 'A*', defined($opt{std_input}) ? delete($opt{std_input}) : ( do { my @lines = split(/\R/, $text); say for @lines[0 .. $#lines - 1]; $term->readline($lines[-1]); } // return ':return' ) ) =~ s/^\s+//r; return q{:next} if $input eq q{}; # for the next page require Encode; $input = Encode::decode_utf8($input); my ($args, $keywords) = parse_options($input); if ($opt{history}) { my $str = join(' ', grep { /\w/ } @{$args}, @{$keywords}); if ($str ne '' and $str !~ /^[0-9]{1,3}\z/) { eval { $term->append_history(1, $opt{history_file}) }; } } apply_input_arguments($args, $keywords); return @{$keywords}; } sub favorite_videos { my (@videos) = @_; foreach my $video_data (@videos) { prepend_video_data_to_file($video_data, $favorite_videos_data_file); } return 1; } sub rate_videos { my $rating = shift; my $file = ($rating eq 'like') ? $liked_videos_data_file : $disliked_videos_data_file; foreach my $video_data (@_) { prepend_video_data_to_file($video_data, $file); } return 1; } sub get_and_play_video_ids { (my @ids = grep { defined($_) } map { get_valid_video_id($_) } @_) || return; foreach my $id (@ids) { my $info = $yv_obj->video_details($id); if (ref($info) eq 'HASH' and keys %$info) { ## OK } else { $info->{title} = "unknown"; $info->{lengthSeconds} = 0; $info->{videoId} = $id; warn_cant_do('get info for', $id); } play_videos([$info]) || return; } return 1; } sub get_and_play_playlists { foreach my $id (@_) { my $videos = $yv_obj->videos_from_playlist_id(get_valid_playlist_id($id) // next); local $opt{play_all} = length($opt{std_input}) ? 0 : 1; print_videos($videos, auto => $opt{play_all}); } return 1; } sub get_and_print_video_info { foreach my $id (@_) { my $videoID = get_valid_video_id($id) // next; my $info = $yv_obj->video_details($videoID); if (ref($info) eq 'HASH' and keys %$info) { local $opt{show_video_info} = 1; print_video_info($info); } else { warn_cant_do('get info for', $videoID); } } return 1; } sub get_and_print_related_videos { foreach my $id (@_) { my $videoID = get_valid_video_id($id) // next; my $results = $yv_obj->related_to_videoID($videoID); print_videos($results); } return 1; } sub get_and_print_comments { foreach my $id (@_) { my $videoID = get_valid_video_id($id) // next; my $comments = $yv_obj->comments_from_video_id($videoID); print_comments($comments, $videoID); } return 1; } sub get_and_print_videos_from_playlist { my ($playlistID) = @_; if ($playlistID =~ /$valid_playlist_id_re/) { my $info = $yv_obj->videos_from_playlist_id($playlistID); if ($yv_utils->has_entries($info)) { print_videos($info); } else { warn colored("\n[!] Inexistent playlist...", 'bold red') . "\n"; return; } } else { warn_invalid('playlistID', $playlistID); return; } return 1; } sub _bold_color { my ($text) = @_; return colored($text, 'bold'); } sub youtube_urls { my ($arg) = @_; if ($yv_utils->is_channelID($arg)) { print_videos($yv_obj->uploads($arg)); } elsif ($yv_utils->is_playlistID($arg)) { get_and_print_videos_from_playlist($arg); } elsif ($arg =~ /$get_video_id_re/) { get_and_play_video_ids($+{video_id}); } elsif ($arg =~ /$get_playlist_id_re/) { get_and_print_videos_from_playlist($+{playlist_id}); } elsif ($arg =~ /$get_channel_playlists_id_re/) { print_playlists($yv_obj->playlists($+{channel_id})); } elsif ($arg =~ /$get_channel_videos_id_re/) { print_videos($yv_obj->uploads($+{channel_id})); } elsif ($arg =~ /$get_username_playlists_re/) { print_playlists($yv_obj->playlists($+{username})); } elsif ($arg =~ /$get_username_videos_re/) { print_videos($yv_obj->uploads($+{username})); } else { return; } return 1; } sub general_options { my %args = @_; my $url = $args{url}; my $option = $args{opt}; my $callback = $args{sub}; my $results = $args{res}; my $info = $args{info}; my $token = undef; my $has_token = 0; if (ref($info->{results}) eq 'HASH' and exists $info->{results}{continuation}) { $has_token = 1; $token = $info->{results}{continuation}; } if (not defined($option)) { return; } if ($option =~ /^(?:q|quit|exit)\z/) { main_quit(0); } elsif ($option =~ /^(?:n|next)\z/ and (defined($url) or ref($token) eq 'CODE')) { if ($has_token) { if (defined $token) { my $request = $yv_obj->next_page($url, $token); $callback->($request); } else { warn_last_page(); } } else { my $request = $yv_obj->next_page($url); $callback->($request); } } elsif ($option =~ /^(?:R|refresh)\z/ and defined($url)) { ##@{$results} = @{$yv_obj->_get_results($url)->{results}}; } elsif ($option =~ /^dv${digit_or_equal_re}(.*)/ and ref($results) eq 'ARRAY') { if (my @nums = get_valid_numbers($#{$results}, $1)) { print "\n"; foreach my $num (@nums) { require Data::Dump; say Data::Dump::pp($results->[$num]); } press_enter_to_continue(); } else { warn_no_thing_selected('result'); } } elsif ($option =~ /^v(?:ideoids?)?=(.*)/) { if (my @ids = split(/[,\s]+/, $1)) { get_and_play_video_ids(@ids); } else { warn colored("\n[!] No video ID specified!", 'bold red') . "\n"; } } elsif ($option =~ /^playlist(?:ID)?=(.*)/) { get_and_print_videos_from_playlist($1); } else { return; } return 1; } sub warn_no_results { warn colored("\n[!] No $_[0] results!", 'bold red') . "\n"; } sub warn_invalid { my ($name, $option) = @_; warn colored("\n[!] Invalid $name: <$option>", 'bold red') . "\n"; } sub warn_cant_do { my ($action, @ids) = @_; foreach my $videoID (@ids) { warn colored("\n[!] Can't $action video: " . sprintf($opt{youtube_video_url}, $videoID), 'bold red') . "\n"; my %info = $yv_obj->_get_video_info($videoID); my $resp = parse_json_string($info{player_response} // next); if (eval { exists($resp->{playabilityStatus}) and $resp->{playabilityStatus}{status} =~ /error/i }) { warn colored("[+] Reason: $resp->{playabilityStatus}{reason}.", 'bold yellow') . "\n"; } } } sub warn_last_page { warn colored("\n[!] This is the last page!", "bold red") . "\n"; } sub warn_first_page { warn colored("\n[!] This is the first page!", 'bold red') . "\n"; } sub warn_no_thing_selected { warn colored("\n[!] No $_[0] selected!", 'bold red') . "\n"; } # ... GET INPUT SUBS ... # sub get_input_for_first_time { return get_user_input(_bold_color("\n=>> Search for YouTube videos (:h for help)") . "\n> "); } sub get_input_for_channels { return get_user_input(_bold_color("\n=>> Select a channel (:h for help)") . "\n> "); } sub get_input_for_search { return get_user_input(_bold_color("\n=>> Select one or more videos to play (:h for help)") . "\n> "); } sub get_input_for_playlists { return get_user_input(_bold_color("\n=>> Select a playlist (:h for help)") . "\n> "); } sub get_input_for_comments { return get_user_input(_bold_color("\n=>> Press for the next page of comments (:h for help)") . "\n> "); } sub get_input_for_categories { return get_user_input(_bold_color("\n=>> Select a category (:h for help)") . "\n> "); } sub ask_yn { my (%opt) = @_; my $c = join('/', map { $_ eq $opt{default} ? ucfirst($_) : $_ } qw(y n)); my $answ; do { $answ = lc($term->readline($opt{prompt} . " [$c]: ")); $answ = $opt{default} unless $answ =~ /\S/; } while ($answ !~ /^y(?:es)?$/ and $answ !~ /^no?$/); return chr(ord($answ)) eq 'y'; } sub get_reply { my (%opt) = @_; my $default = 1; while (my ($i, $choice) = each @{$opt{choices}}) { print "\n" if $i == 0; printf("%3d> %s\n", $i + 1, $choice); if ($choice eq $opt{default}) { $default = $i + 1; } } print "\n"; my $answ; do { $answ = $term->readline($opt{prompt} . " [$default]: "); $answ = $default unless $answ =~ /\S/; } while ($answ !~ /^[0-9]+\z/ or $answ < 1 or $answ > @{$opt{choices}}); return $opt{choices}[$answ - 1]; } sub valid_num { my ($num, $array_ref) = @_; return $num =~ /^[0-9]{1,3}\z/ && $num != 0 && $num <= @{$array_ref}; } sub adjust_width { my ($str, $len, $prepend) = @_; if ($len <= 0) { return $str; } state $pkg = ( eval { require Unicode::GCString; Unicode::GCString->new('test'); 'Unicode::GCString'; } // eval { require Text::CharWidth; 'Text::CharWidth'; } // do { warn "[WARN] Please install Unicode::GCString or Text::CharWidth in order to use this functionality.\n"; ''; } ); my $adjust_str = sub { # Unicode::GCString if ($pkg eq 'Unicode::GCString') { my $gcstr = Unicode::GCString->new($str); my $str_width = $gcstr->columns; while ($str_width > $len) { $gcstr = $gcstr->substr(0, -1); $str_width = $gcstr->columns; } $str = $gcstr->as_string; return ($str, $str_width); } # Text::CharWidth if ($pkg eq 'Text::CharWidth') { my $str_width = Text::CharWidth::mbswidth($str); while ($str_width > $len) { chop $str; $str_width = Text::CharWidth::mbswidth($str); } return ($str, $str_width); } # Fallback to counting graphemes my @graphemes = $str =~ /(\X)/g; while (scalar(@graphemes) > $len) { pop @graphemes; } $str = join('', @graphemes); return ($str, scalar(@graphemes)); }; my ($new_str, $str_width) = $adjust_str->(); my $spaces = ' ' x ($len - $str_width); my $result = $prepend ? join('', $spaces, $new_str) : join('', $new_str, $spaces); return $result; } sub format_line_result { my ($i, $entry, $info, %args) = @_; if (ref($entry) eq '') { $entry =~ s/\*NO\*/sprintf('%2d', $i+1)/ge; $entry = $yv_utils->format_text( info => $info, text => $entry, escape => 0, ); return "$entry\n"; } if (ref($entry) eq 'ARRAY') { my @columns; foreach my $slot (@$entry) { my $text = $slot->{text}; my $width = $slot->{width} // 10; my $color = $slot->{color}; my $align = $slot->{align} // 'left'; if ($width =~ /^(\d+)%\z/) { $width = int(($term_width * $1) / 100); } $text =~ s/\*NO\*/$i+1/ge; $text = $yv_utils->format_text( info => $info, text => $text, escape => 0, ); $text = clear_title($text); $text = adjust_width($text, $width, ($align eq 'right')); if (defined($color)) { $text = colored($text, $color); } push @columns, $text; } return (join(' ', @columns) . "\n"); } die "ERROR: invalid custom layout format <<$entry>>\n"; } # ... PRINT SUBROUTINES ... # sub print_channels { my ($results) = @_; if (not $yv_utils->has_entries($results)) { warn_no_results("channel"); } if ($opt{get_term_width}) { get_term_width(); } my $url = $results->{url}; my $channels = $results->{results} // []; if (ref($channels) eq 'HASH') { if (exists $channels->{channels}) { $channels = $channels->{channels}; } elsif (exists $channels->{entries}) { $channels = $channels->{entries}; } else { warn "\n[!] No channels...\n"; $channels = []; } } my @formatted; foreach my $i (0 .. $#{$channels}) { my $channel = $channels->[$i]; my $entry = $opt{custom_channel_layout_format}; push @formatted, format_line_result($i, $entry, $channel); } if (@formatted) { print "\n" . join("", @formatted); } my @keywords = get_input_for_channels(); my @for_search; foreach my $key (@keywords) { if ($key =~ /$valid_opt_re/) { my $opt = $1; if ( general_options( opt => $opt, sub => __SUB__, url => $url, res => $channels, info => $results, ) ) { ## ok } # :h, :help elsif ($opt =~ /^(?:h|help)\z/) { print $channels_help; press_enter_to_continue(); } # :r, :return elsif ($opt =~ /^(?:r|return)\z/) { return; } # :i=i, :info=i elsif ($opt =~ /^(?:i|info)${digit_or_equal_re}(.*)/) { if (my @ids = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@ids) { print_channel_info($channels->[$id]); } press_enter_to_continue(); } else { warn_no_thing_selected('playlist'); } } # :us=i, :streams=i # :shorts=i elsif ($opt =~ /^(us|streams|shorts)${digit_or_equal_re}(.*)/) { my $type = $1; if (my @nums = get_valid_numbers($#{$channels}, $2)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $request = ($type =~ /shorts/) ? $yv_obj->shorts($channel_id) : $yv_obj->streams($channel_id); if ($yv_utils->has_entries($request)) { print_videos($request); } else { warn_no_results($type =~ /shorts/ ? 'shorts' : 'streams'); } } } else { warn_no_thing_selected('channel'); } } # :pv=i, :popular=i elsif ($opt =~ /^(?:pv|popular)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $request = $yv_obj->popular_videos($channel_id); if ($yv_utils->has_entries($request)) { print_videos($request); } else { warn_no_results('popular video'); } } } else { warn_no_thing_selected('channel'); } } # :ps=i, :pstreams=i, :popular-streams=i # :pshorts=i, :popular-shorts=i elsif ($opt =~ /^(ps|pstreams|popular-streams|pshorts|popular-shorts)${digit_or_equal_re}(.*)/) { my $type = $1; if (my @nums = get_valid_numbers($#{$channels}, $2)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $request = ($type =~ /shorts/) ? $yv_obj->popular_shorts($channel_id) : $yv_obj->popular_streams($channel_id); if ($yv_utils->has_entries($request)) { print_videos($request); } else { warn_no_results('popular ' . ($type =~ /shorts/ ? 'shorts' : 'streams')); } } } else { warn_no_thing_selected('channel'); } } # :p=i, :playlist=i, :up=i elsif ($opt =~ /^(?:p|l|playlists?|up)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $request = $yv_obj->playlists($channel_id); if ($yv_utils->has_entries($request)) { print_playlists($request); } else { warn_no_results('playlist'); } } } else { warn_no_thing_selected('channel'); } } # :s=i, :subscribe=i elsif ($opt =~ /^(?:s|sub(?:scribe)?)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $channel_title = $yv_utils->get_channel_title($channels->[$id]); subscribe_channel($channel_id, $channel_title); } } else { warn_no_thing_selected('channel'); } } # :save=i elsif ($opt =~ /^(?:save)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $channel_title = $yv_utils->get_channel_title($channels->[$id]); save_channel($channel_id, $channel_title); } } else { warn_no_thing_selected('channel'); } } # :r=i, :rm=i, :remove=i elsif ($opt =~ /^(?:r|rm|remove)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { remove_saved_channels(map { $yv_utils->get_channel_id($channels->[$_]) } @nums); } else { warn_no_thing_selected('channel'); } } # :unsub=i, :unsubscribe=i elsif ($opt =~ /^(?:unsub(?:scribe)?)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { unsubscribe_from_channels(map { $yv_utils->get_channel_id($channels->[$_]) } @nums); } else { warn_no_thing_selected('channel'); } } else { warn_invalid('option', $opt); } } elsif (youtube_urls($key)) { ## ok } elsif (valid_num($key, $channels)) { print_videos($yv_obj->uploads($yv_utils->get_channel_id($channels->[$key - 1]))); } else { push @for_search, $key; } } if (@for_search) { __SUB__->($yv_obj->search_channels(\@for_search)); } __SUB__->(@_); } sub print_comments { my ($results, $videoID) = @_; if (not $yv_utils->has_entries($results)) { warn_no_results("comments"); } my $url = $results->{url}; my $comments = $results->{results}{comments} // []; foreach my $comment (@{$comments}) { my $comment_age = $yv_utils->get_publication_age($comment); if ($comment_age) { if ($comment_age !~ / ago\b/) { $comment_age = "$comment_age ago"; } } else { $comment_age = $yv_utils->get_publication_date($comment) // 'unknown'; } printf( "\n%s (%s) commented:\n%s\n", colored($yv_utils->get_author($comment), 'bold'), $comment_age, wrap_text( i_tab => q{ } x 3, s_tab => q{ } x 3, text => [$yv_utils->get_comment_content($comment) // 'Empty comment...'] ), ) if not $comment->{_hidden}; if (exists($comment->{replies}) and ref($comment->{replies}) eq 'ARRAY') { foreach my $reply (@{$comment->{replies}}) { my $reply_age = $yv_utils->get_publication_age($reply); if ($reply_age) { $reply_age = "$reply_age ago"; } else { $reply_age = $yv_utils->get_publication_date($reply) // 'unknown'; } printf( "\n %s (%s) replied:\n%s\n", colored($yv_utils->get_author($reply), 'bold'), $reply_age, wrap_text( i_tab => q{ } x 6, s_tab => q{ } x 6, text => [$yv_utils->get_comment_content($reply) // 'Empty reply...'] ), ); } } } my @keywords = get_input_for_comments(); foreach my $key (@keywords) { if ($key =~ /$valid_opt_re/) { my $opt = $1; if ( general_options( opt => $opt, sub => __SUB__, url => $url, res => $comments, info => $results, mode => 'comments', args => [$videoID], ) ) { ## ok } elsif ($opt =~ /^(?:h|help)\z/) { print $comments_help; press_enter_to_continue(); } elsif ($opt =~ /^(?:r|return)\z/) { return; } else { warn_invalid('option', $opt); } } elsif (youtube_urls($key)) { ## ok } elsif (valid_num($key, $comments)) { print_videos($yv_obj->uploads($comments->[$key - 1]{authorId})); } else { warn_invalid('keyword', $key); } } __SUB__->(@_); } sub _add_channel_to_file { my ($channel_id, $channel_title, $file) = @_; $channel_id // return; if ($channel_id = extract_channel_id($channel_id)) { if (not $yv_utils->is_channelID($channel_id)) { $channel_id = $yv_obj->channel_id_from_username($channel_id) // do { warn_invalid("username or channel ID", $channel_id); undef; }; } } $channel_id // return; $channel_title //= $yv_obj->channel_title_from_id($channel_id) // $channel_id; if (not defined($channel_title)) { warn "[!] Could not determine the channel name...\n"; return; } say ":: Saving channel <<$channel_title>> (id: $channel_id) to file..." if $yv_obj->get_debug; open(my $fh, '>>:utf8', $file) or do { warn "[!] Can't open file <<$file>> for appending: $!\n"; return; }; say $fh "$channel_id $channel_title"; close $fh; } sub save_channel { my ($channel_id, $channel_title) = @_; _add_channel_to_file($channel_id, $channel_title, $opt{saved_channels_file}); } sub subscribe_channel { my ($channel_id, $channel_title) = @_; _add_channel_to_file($channel_id, $channel_title, $opt{saved_channels_file}); _add_channel_to_file($channel_id, $channel_title, $opt{subscribed_channels_file}); } sub update_channel_file { my ($channels, $file) = @_; open(my $fh, '>:utf8', $file) or do { warn "[!] Can't open file <<$file>> for writing: $!\n"; return; }; foreach my $key (sort { CORE::fc($channels->{$a}) cmp CORE::fc($channels->{$b}) } keys %$channels) { say $fh "$key $channels->{$key}"; } close $fh; } sub _remove_channels_from_file { my ($channel_ids, $file) = @_; my %channels = map { @$_ } $yv_utils->read_channels_from_file($file); my $removed = 0; foreach my $channel_id (@$channel_ids) { if (exists $channels{$channel_id}) { say ":: Removing: $channel_id" if $yv_obj->get_debug; delete $channels{$channel_id}; ++$removed; } else { say ":: $channel_id is not a saved channel..." if $yv_obj->get_debug; } } if ($removed > 0) { update_channel_file(\%channels, $file); } } sub remove_saved_channels { my (@channel_ids) = @_; _remove_channels_from_file(\@channel_ids, $opt{saved_channels_file}); _remove_channels_from_file(\@channel_ids, $opt{subscribed_channels_file}); } sub unsubscribe_from_channels { my (@channel_ids) = @_; _remove_channels_from_file(\@channel_ids, $opt{subscribed_channels_file}); } sub get_results_from_list { my ($results, %args) = @_; $args{page} //= $yv_obj->get_page; if (ref($results) ne 'ARRAY') { return; } my @results = @$results; my $maxResults = $yv_obj->get_maxResults; my $totalResults = scalar(@results); if ($args{page} >= 1 and scalar(@results) >= $maxResults) { @results = grep { defined } @results[($args{page} - 1) * $maxResults .. $args{page} * $maxResults - 1]; if (!@results) { warn_last_page() if ($args{page} == 1 + sprintf('%0.f', 0.5 + $totalResults / $maxResults)); return __SUB__->($results, %args, page => $args{page} - 1) if ($args{page} > 1); } } my %results; my @entries; foreach my $entry (@results) { if (defined($args{callback})) { push @entries, $args{callback}($entry); } else { push @entries, $entry; } } #<<< $results{entries} = \@entries; #$results{pageInfo} = {resultsPerPage => scalar(@entries), totalResults => $totalResults}; #$results{fromPage} = sub { get_results_from_list($results, %args, page => $_[0]) }; $results{continuation} = sub { get_results_from_list($results, %args, page => ($args{page} + 1)) }; #$results{prevPageToken} = sub { get_results_from_list($results, %args, page => (($args{page} > 1) ? ($args{page} - 1) : do { warn_first_page(); 1 })) }; #>>> scalar {results => \%results, url => undef}; } sub print_local_playlist { my ($name) = @_; $name //= ''; require File::Basename; my @playlist_files = reverse $yv_utils->get_local_playlist_filenames($local_playlists_dir); my $regex = qr/\Q$name\E/i; if ($name eq '') { my $results = get_results_from_list( \@playlist_files, callback => sub { my ($id) = @_; $yv_utils->local_playlist_snippet($id); } ); return print_playlists($results); } foreach my $file (@playlist_files) { if (File::Basename::basename($file) =~ $regex or $file eq $name) { return print_videos_from_data_file($file); } } warn_no_thing_selected('playlist'); return 0; } sub print_videos_from_data_file { my ($file) = @_; require Storable; my $videos = eval { Storable::retrieve($file) } // []; print_videos(get_results_from_list($videos)); } sub print_watched_videos { print_videos_from_data_file($watch_history_data_file); } sub print_liked_videos { print_videos_from_data_file($liked_videos_data_file); } sub print_disliked_videos { print_videos_from_data_file($disliked_videos_data_file); } sub print_favorite_videos { print_videos_from_data_file($favorite_videos_data_file); } sub print_subscription_videos { print_videos_from_data_file($subscription_videos_data_file); } sub fetch_channel_latest_videos { my ($channel_id) = @_; my @results; foreach my $method (grep { /^\w+\z/ } map { split(/,/, $_) } split(' ', $opt{subscription_results})) { my $uploads = $yv_obj->$method($channel_id) // next; my $videos = $uploads->{results} // []; if (ref($videos) eq 'HASH' and exists $videos->{videos}) { $videos = $videos->{videos}; } if (ref($videos) eq 'HASH' and exists $videos->{entries}) { $videos = $videos->{entries}; } if (ref($videos) ne 'ARRAY') { next; } push @results, @$videos; } @results ? \@results : undef; } sub print_local_subscription_videos { state $t0 = time; state $d0 = $t0; # Reuse the subscription file if it's less than 10 minutes old if ( $d0 != $t0 and (time - $t0 <= $opt{subscriptions_lifetime}) and (-f $subscription_videos_data_file) and (-M $subscription_videos_data_file) < ((-M $opt{subscribed_channels_file}) // 0)) { return print_subscription_videos(); } $t0 = time + 1; my @channels = $yv_utils->read_channels_from_file($opt{subscribed_channels_file}); if (not @channels) { warn "\n[!] No subscribed channels...\n"; return; } print "\n" if @channels; require Time::Piece; my $time = Time::Piece->new(); my @items; if ($opt{get_subscriptions_in_parallel} and eval { require Parallel::ForkManager; 1 }) { # Disable connection cache, as it fails when parallel requests are made my $lwp = $yv_obj->{lwp} // $yv_obj->set_lwp_useragent(); $lwp->conn_cache(undef); say ":: Retrieving subscription videos..."; # Max number of processes my $pm = Parallel::ForkManager->new(30); $pm->set_waitpid_blocking_sleep(0.1); # Data structure retrieval and handling $pm->run_on_finish( sub { my ($pid, $exit_code, $ident, $exit_signal, $core_dump, $videos) = @_; if ($exit_code == 0 and defined($videos) and ref($videos) eq 'ARRAY') { push @items, @$videos; } } ); foreach my $i (0 .. $#channels) { my $id = $channels[$i][0] // next; my $pid = $pm->start($id) // next; next if ($pid != 0); my $videos = fetch_channel_latest_videos($id) // do { $pm->finish(1, []); next; }; foreach my $video (@$videos) { $video->{timestamp} = [@$time]; } $pm->finish(0, $videos); } $pm->wait_all_children; } else { foreach my $i (0 .. $#channels) { local $| = 1; printf("[%d/%d] Retrieving info for $channels[$i][1]...\r", $i + 1, $#channels + 1); my $id = $channels[$i][0] // next; my $videos = fetch_channel_latest_videos($id) // next; foreach my $video (@$videos) { $video->{timestamp} = [@$time]; } push @items, @$videos; } print "\n" if @items; } my $subscriptions_data = []; if (-f $subscription_videos_data_file) { require Storable; $subscriptions_data = eval { Storable::retrieve($subscription_videos_data_file) } // []; } unshift(@$subscriptions_data, @items); # Remove duplicates @$subscriptions_data = do { my %seen; grep { !$seen{$yv_utils->get_video_id($_)}++ } @$subscriptions_data; }; my %subscriptions = ((map { @$_ } @channels), (map { lc($_->[0]) => 1 } @channels)); # Remove videos from unsubscribed channels @$subscriptions_data = grep { exists($subscriptions{$yv_utils->get_channel_id($_)}) or exists($subscriptions{lc($yv_utils->get_channel_title($_) // '')}) } @$subscriptions_data; # Order videos by newest first @$subscriptions_data = map { $_->[0] } sort { $b->[1] <=> $a->[1] } map { [$_, $yv_utils->get_publication_time($_)] } @$subscriptions_data; # Remove results from the end when the list becomes too large my $subscriptions_limit = $opt{subscriptions_limit} // 1e4; if ($subscriptions_limit > 0 and scalar(@$subscriptions_data) > $subscriptions_limit) { $#$subscriptions_data = $subscriptions_limit; } if (@$subscriptions_data) { require Storable; Storable::store([grep { $yv_utils->get_time($_) ne 'LIVE' } @$subscriptions_data], $subscription_videos_data_file); } print_videos(get_results_from_list($subscriptions_data)); } sub _print_local_channel_from_file { my ($name, $file) = @_; $name //= ''; my @users; if (-e $file) { @users = $yv_utils->read_channels_from_file($file); } else { @users = $yv_utils->default_channels; } my $regex = qr/\Q$name\E/i; if ($name eq '') { my $results = get_results_from_list( \@users, callback => sub { my ($entry) = @_; my ($id, $name) = @$entry; $yv_utils->local_channel_snippet($id, $name); } ); return print_channels($results); } foreach my $user (@users) { my ($channel_id, $channel_name) = @$user; if ($channel_id eq $name or $channel_name =~ $regex) { return print_videos($yv_obj->uploads($channel_id)); } } warn_no_thing_selected('channel'); return 0; } sub print_saved_channels { my ($name) = @_; _print_local_channel_from_file($name, $opt{saved_channels_file}); } sub print_subscribed_channels { my ($name) = @_; _print_local_channel_from_file($name, $opt{subscribed_channels_file}); } sub print_categories { my ($results) = @_; my $categories = $results; return if ref($categories) ne 'ARRAY'; my $i = 0; print "\n" if @{$categories}; foreach my $category (@{$categories}) { printf "%s. %-40s\n", colored(sprintf('%2d', ++$i), 'bold'), $category->{title}; } my @keywords = get_input_for_categories(); foreach my $key (@keywords) { if ($key =~ /$valid_opt_re/) { my $opt = $1; if ( general_options( opt => $opt, sub => __SUB__, res => $results, ) ) { ## ok } elsif ($opt =~ /^(?:h|help)\z/) { print $general_help; press_enter_to_continue(); } elsif ($opt =~ /^(?:r|return)\z/) { return; } else { warn_invalid('option', $opt); } } elsif (youtube_urls($key)) { ## ok } elsif (valid_num($key, $categories)) { my $category = $categories->[$key - 1]; my $cat_id = $category->{id}; my $videos = $yv_obj->trending_videos_from_category($cat_id); print_videos($videos); } else { warn_invalid('keyword', $key); } } __SUB__->(@_); } sub print_playlists { my ($results, %args) = @_; if (not $yv_utils->has_entries($results)) { warn_no_results("playlist"); } if ($opt{get_term_width}) { get_term_width(); } my $url = $results->{url}; my $playlists = $results->{results} // []; if (ref($playlists) eq 'HASH') { if (exists $playlists->{playlists}) { $playlists = $playlists->{playlists}; } elsif (exists $playlists->{entries}) { $playlists = $playlists->{entries}; } else { warn "\n[!] No playlists...\n"; $playlists = []; } } my @formatted; foreach my $i (0 .. $#{$playlists}) { my $playlist = $playlists->[$i]; my $entry = $opt{custom_playlist_layout_format}; push @formatted, format_line_result($i, $entry, $playlist); } if (@formatted) { print "\n" . join("", @formatted); } state @keywords; if ($args{auto}) { } # do nothing... else { @keywords = get_input_for_playlists(); if (scalar(@keywords) == 0) { __SUB__->(@_); } } my $contains_keywords = grep { /$non_digit_or_opt_re/ } @keywords; my @for_search; foreach my $key (@keywords) { if ($key =~ /$valid_opt_re/) { my $opt = $1; if ( general_options( opt => $opt, sub => __SUB__, url => $url, res => $playlists, info => $results, mode => 'playlists', ) ) { ## ok } elsif ($opt =~ /^(?:h|help)\z/) { print $playlists_help; press_enter_to_continue(); } elsif ($opt =~ /^(?:r|return)\z/) { return; } # :i=i, :info=i elsif ($opt =~ /^(?:i|info)${digit_or_equal_re}(.*)/) { if (my @ids = get_valid_numbers($#{$playlists}, $1)) { foreach my $id (@ids) { print_playlist_info($playlists->[$id]); } press_enter_to_continue(); } else { warn_no_thing_selected('playlist'); } } # :p=i, :playlist=i, :up=i elsif ($opt =~ /^(?:p|l|playlists?|up)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$playlists}, $1)) { foreach my $id (@nums) { my $request = $yv_obj->playlists($yv_utils->get_channel_id($playlists->[$id])); if ($yv_utils->has_entries($request)) { print_playlists($request); } else { warn_no_results('playlist'); } } } else { warn_no_thing_selected('playlist'); } } # :pp=i elsif ($opt =~ /^pp${digit_or_equal_re}(.*)/) { if (my @ids = get_valid_numbers($#{$playlists}, $1)) { my $arg = "--pp=" . join(q{,}, map { $yv_utils->get_playlist_id($_) } @{$playlists}[@ids]); apply_input_arguments([$arg]); } else { warn_no_thing_selected('playlist'); } } else { warn_invalid('option', $opt); } } elsif (youtube_urls($key)) { ## ok } elsif (valid_num($key, $playlists) and not $contains_keywords) { my $id = $yv_utils->get_playlist_id($playlists->[$key - 1]); if ($args{return_playlist_id}) { return $id; } if ($id =~ m{^/}) { # local playlist print_local_playlist($id); } else { get_and_print_videos_from_playlist($id); } } else { push @for_search, $key; } } if (@for_search) { __SUB__->($yv_obj->search_playlists(\@for_search)); } __SUB__->(@_); } sub compile_regex { my ($value) = @_; #~ $value =~ s{^(?['"])(?.+)\g{quote}$}{$+{regex}}s; my $re = eval { use re qw(eval); qr/$value/i }; if ($@) { warn_invalid("regex", $@); return; } return $re; } sub get_range_numbers { my ($first, $second) = @_; return ( $first > $second ? (reverse($second .. $first)) : ($first .. $second) ); } sub get_valid_numbers { my ($max, $input) = @_; my @output; foreach my $id (split(/[,\s]+/, $input)) { push @output, $id =~ /$range_num_re/ ? get_range_numbers($1, $2) : $id =~ /^[0-9]{1,3}\z/ ? $id : next; } return grep { $_ >= 0 and $_ <= $max } map { $_ - 1 } @output; } sub get_streaming_url { my ($video_id) = @_; my ($urls, $captions, $info) = $yv_obj->get_streaming_urls($video_id); if (not defined $urls) { return scalar {}; } # Download the closed-captions my $srt_file; if (ref($captions) eq 'ARRAY' and @$captions and $opt{get_captions} and not $opt{novideo}) { require WWW::PipeViewer::GetCaption; my $languages = $opt{srt_languages}; if (ref($languages) ne 'ARRAY') { $languages = [grep { /[a-z]/i } split(/\s*,\s*/, $languages)]; } my $yv_cap = WWW::PipeViewer::GetCaption->new( auto_captions => $opt{auto_captions}, captions_dir => $opt{cache_dir}, captions => $captions, languages => $languages, yv_obj => $yv_obj, ); $srt_file = $yv_cap->save_caption($video_id); } require WWW::PipeViewer::Itags; state $yv_itags = WWW::PipeViewer::Itags->new(); # Include split-videos my $split_videos = 1; # Exclude split-videos in download-mode or when no video output is required if ($opt{novideo} or not $opt{split_videos}) { $split_videos = 0; } elsif ($opt{download_video}) { $split_videos = $opt{merge_into_mkv} ? 1 : 0; } my ($streaming, $resolution) = $yv_itags->find_streaming_url( urls => $urls, resolution => ($opt{novideo} ? 'audio' : $opt{resolution}), hfr => $opt{hfr}, ignore_av1 => $opt{ignore_av1}, split => $split_videos, prefer_m4a => $opt{prefer_m4a}, audio_quality => $opt{audio_quality}, dash => ($opt{download_video} ? 0 : $opt{dash}), ignored_projections => $opt{ignored_projections}, ); return { streaming => $streaming, srt_file => $srt_file, info => $info, resolution => $resolution, }; } sub download_from_url { my ($info, $output_filename) = @_; my $url = $info->{url}; # Download with yt-dlp / youtube-dl if ($opt{ytdl} and $opt{download_with_ytdl} and defined($info->{_youtube_url}) and defined($info->{itag})) { # TODO: take into account the preferred audio quality and format and video resolution # and format when falling back (i.e. when yt-dlp cannot find the itag that we want) my $cmd = join(' ', $opt{ytdl_cmd}, ($info->{wkad} ? () : ('-f', $info->{itag} . ($info->{type} =~ m{^audio/} ? '/bestaudio' : '/bestvideo'))), quotemeta($info->{_youtube_url}), '-o', quotemeta("$output_filename.part")); if ($yv_obj->get_debug) { say "-> Command: $cmd"; } $yv_obj->proxy_system($cmd); return if $?; rename("$output_filename.part", $output_filename) or return undef; return $output_filename; } # Download with wget if ($opt{download_with_wget}) { my $cmd = join(' ', $opt{wget_cmd}, '-c', '-t', '10', '--waitretry=3', quotemeta($url), '-O', quotemeta("$output_filename.part")); $yv_obj->proxy_system($cmd); return if $?; rename("$output_filename.part", $output_filename) or return undef; return $output_filename; } state $lwp_dl = which_command('lwp-download'); # Download with lwp-download if (defined($lwp_dl)) { my @cmd = ($lwp_dl, $url, "$output_filename.part"); $yv_obj->proxy_system(@cmd); if ($? == 256 and !defined(fileno(STDOUT))) { # lwp-download bug ## ok } else { return if $?; } rename("$output_filename.part", $output_filename) or return undef; return $output_filename; } # Download with LWP::UserAgent require LWP::UserAgent; my $lwp = LWP::UserAgent->new(show_progress => 1, agent => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',); $lwp->proxy(['http', 'https'], $yv_obj->get_http_proxy) if defined($yv_obj->get_http_proxy); my $resp = eval { $lwp->mirror($url, "$output_filename.part") }; if ($@ =~ /\bread timeout\b/i or not defined($resp) or not $resp->is_success) { warn colored("\n[!] Encountered an error while downloading... Trying again...", 'bold red') . "\n\n"; if (defined(my $wget_path = which_command('wget'))) { $CONFIG{wget_cmd} = $wget_path; $CONFIG{download_with_wget} = 1; dump_configuration($config_file); } else { warn colored("[!] Please install `wget` and try again...", 'bold red') . "\n\n"; } unlink("$output_filename.part"); return download_from_url($info, $output_filename); } rename("$output_filename.part", $output_filename) or return undef; return $output_filename; } sub download_video { my ($streaming, $info) = @_; my $video_filename = $yv_utils->normalize_filename( $yv_utils->format_text( streaming => $streaming, info => $info, text => $opt{video_filename_format}, escape => 0, ), $opt{fat32safe} ); $video_filename =~ s/\h*:+\h*/ - /g; # replace colons (":") with dashes ("-") my $naked_filename = $video_filename =~ s/\.\w+\z//r; my $mkv_filename = "$naked_filename.mkv"; my $srt_filename = "$naked_filename.srt"; my $audio_filename = "$naked_filename - audio"; my $video_info = $streaming->{streaming}; my $audio_info = $streaming->{streaming}{__AUDIO__}; my $video_id = $yv_utils->format_text( streaming => $streaming, info => $info, text => '*ID*', escape => 0 ); $video_info->{_youtube_url} = sprintf("https://youtube.com/watch?v=%s", $video_id); if ($audio_info) { $audio_filename .= "." . $yv_utils->extension($audio_info->{type}); $audio_info->{_youtube_url} = $video_info->{_youtube_url}; } my $downloads_dir = $opt{downloads_dir}; # Download in subdirectory if ($opt{download_in_subdir}) { my $downloads_subdir = $yv_utils->normalize_filename( $yv_utils->format_text( streaming => $streaming, info => $info, text => $opt{download_in_subdir_format}, escape => 0, ), $opt{fat32safe} ); $downloads_dir = catdir($downloads_dir, $downloads_subdir); } # Create the downloads directory, when it doesn't exist if (not -d $downloads_dir) { require File::Path; if (not eval { File::Path::make_path($downloads_dir) }) { warn colored("\n[!] Can't create directory <<$downloads_dir>>: $1", 'bold red') . "\n"; } } if (not -d $downloads_dir) { warn colored("\n[!] Can't write into directory <<$downloads_dir>>: $!", 'bold red') . "\n"; $downloads_dir = (-d curdir()) ? curdir() : (-d $ENV{HOME}) ? $ENV{HOME} : return; warn colored("[!] Video will be downloaded into directory: $downloads_dir", 'bold red') . "\n"; } $mkv_filename = catfile($downloads_dir, $mkv_filename); $srt_filename = catfile($downloads_dir, $srt_filename); $audio_filename = catfile($downloads_dir, $audio_filename); $video_filename = catfile($downloads_dir, $video_filename); if ($opt{skip_if_exists} and -e $mkv_filename) { $video_filename = $mkv_filename; say ":: File `$mkv_filename` already exists. Skipping..."; } else { if ($opt{skip_if_exists} and -e $video_filename) { say ":: File `$video_filename` already exists. Skipping..."; } else { $video_filename = download_from_url($video_info, $video_filename) // return; } if ($opt{skip_if_exists} and -e $audio_filename) { say ":: File `$audio_filename` already exists. Skipping..."; } elsif ($audio_info) { $audio_filename = download_from_url($audio_info, $audio_filename) // return; } } my @merge_files = ($video_filename); if ($audio_info) { push @merge_files, $audio_filename; } if ( $opt{merge_with_captions} and defined($streaming->{srt_file}) and -f $streaming->{srt_file}) { push @merge_files, $streaming->{srt_file}; } if ( $opt{merge_into_mkv} and scalar(@merge_files) > 1 and scalar(grep { -f $_ } @merge_files) == scalar(@merge_files) and not -e $mkv_filename) { say ":: Merging into MKV..."; my $ffmpeg_cmd = $opt{ffmpeg_cmd}; my $ffmpeg_args = $opt{merge_into_mkv_args}; if (my @srt_files = grep { /\.srt\z/ } @merge_files) { my $srt_file = $srt_files[0]; require File::Basename; if (File::Basename::basename($srt_file) =~ m{^.{11}_([a-z]{2,4})}i) { my $lang_code = $1; $ffmpeg_args .= " -metadata:s:s:0 language=$lang_code"; } } my $merge_command = join(' ', $ffmpeg_cmd, (map { "-i \Q$_\E" } @merge_files), $ffmpeg_args, "\Q$mkv_filename\E"); if ($yv_obj->get_debug) { say "-> Command: $merge_command"; } $yv_obj->proxy_system($merge_command); if ($? == 0 and -e $mkv_filename) { unlink @merge_files; $video_filename = $mkv_filename; } elsif ($? != 0) { # ffmpeg failed if (-e $mkv_filename) { # probably due to not enough space unlink $mkv_filename; # remove the mkv file } return; } } # Convert the downloaded video if (defined $opt{convert_to}) { my $convert_filename = catfile($downloads_dir, "$naked_filename.$opt{convert_to}"); my $convert_cmd = $opt{convert_cmd}; my %table = ( 'IN' => $video_filename, 'OUT' => $convert_filename, ); my $regex = do { local $" = '|'; qr/\*(@{[keys %table]})\*/; }; $convert_cmd =~ s/$regex/\Q$table{$1}\E/g; say $convert_cmd if $yv_obj->get_debug; $yv_obj->proxy_system($convert_cmd); if ($? == 0) { if (not $opt{keep_original_video}) { unlink $video_filename or warn colored("\n[!] Can't unlink file <<$video_filename>>: $!", 'bold red') . "\n\n"; } $video_filename = $convert_filename if -e $convert_filename; } } # Play the download video if ($opt{download_and_play}) { local $streaming->{streaming}{url} = ''; local $streaming->{streaming}{__AUDIO__} = undef; local $streaming->{srt_file} = undef if ($opt{merge_into_mkv} && $opt{merge_with_captions}); my $command = get_player_command($streaming, $info); say "-> Command: ", $command if $yv_obj->get_debug; $yv_obj->proxy_system(join(q{ }, $command, quotemeta($video_filename))); # Remove it afterwards if ($? == 0 and $opt{remove_played_file}) { unlink $video_filename or warn colored("\n[!] Can't unlink file <<$video_filename>>: $!", 'bold red') . "\n\n"; } } # Copy the .srt file to downloads-dir if ( $opt{copy_caption} and -e $video_filename and defined($streaming->{srt_file}) and -e $streaming->{srt_file}) { my $from = $streaming->{srt_file}; my $to = $srt_filename; require File::Copy; File::Copy::cp($from, $to); } # Set original modification timestamp if ($opt{set_mtime} and defined($info->{publishedText}) and -f $video_filename) { require Time::Piece; my $published_time = eval { Time::Piece->strptime($info->{publishedText}, "%b %d, %Y") }; if (defined($published_time)) { eval { utime(time, $published_time->epoch, $video_filename) } || warn colored("\n[!] Failed to set modification time of <<$video_filename>>: $!", 'bold red') . "\n\n"; } } return 1; } sub prepend_video_data_to_file { my ($video_data, $file) = @_; require Storable; my $videos = eval { Storable::retrieve($file) } // []; if (ref($video_data) ne 'HASH') { my $videoID = get_valid_video_id($video_data) // return; $video_data = $yv_obj->video_details($videoID); } get_valid_video_id($yv_utils->get_video_id($video_data)) // return; unshift(@$videos, $video_data); my %seen; @$videos = grep { !$seen{$yv_utils->get_video_id($_)}++ } @$videos; if ($opt{local_playlist_limit} > 0 and scalar(@$videos) > $opt{local_playlist_limit}) { $#$videos = $opt{local_playlist_limit} - 1; } Storable::store($videos, $file); return 1; } sub save_watched_video { my ($video_id, $video_data) = @_; if ($opt{watch_history}) { if (not exists($WATCHED_VIDEOS{$video_id})) { $WATCHED_VIDEOS{$video_id} = 1; open my $fh, '>>', $opt{watch_history_file} or return; say {$fh} $video_id; close $fh; } prepend_video_data_to_file($video_data, $watch_history_data_file); } $WATCHED_VIDEOS{$video_id} = 1; return 1; } sub get_player_command { my ($streaming, $video) = @_; my %player_args = (%PLAYER_ARGS); my $player = $opt{video_players}{$opt{video_player_selected}}; if (ref($player) ne 'HASH') { die ":: The selected video player does not exist! Check the configuration file."; } $player_args{fullscreen} = $opt{fullscreen} ? $player->{fs} : undef; $player_args{novideo} = $opt{novideo} ? $player->{novideo} : undef; $player_args{arguments} = $player->{arg}; my $cmd = join( q{ }, ( # Video player $player->{cmd}, ( # Audio file (https://) (ref($streaming->{streaming}{__AUDIO__}) eq 'HASH' && defined($player->{audio})) ? $player->{audio} : () ), ( # Subtitle file (.srt) (defined($streaming->{srt_file}) && defined($player->{srt})) ? $player->{srt} : () ), # Rest of the arguments (grep { defined($_) and /\S/ } values %player_args) ) ); my $has_video = $cmd =~ /\*(?:VIDEO|URL)\*/; $cmd = $yv_utils->format_text( streaming => $streaming, info => $video, text => $cmd, escape => 1, ); if ($streaming->{streaming}{url} =~ m{^https://www\.youtube\.com/watch\?v=}) { $cmd =~ s{\s*--no-ytdl\b}{ }g; } $has_video ? $cmd : join(' ', $cmd, quotemeta($streaming->{streaming}{url})); } sub autoplay { my $video_id = get_valid_video_id(shift) // return; my %seen; # make sure we don't get stuck in a loop local $yv_obj->{maxResults} = 10; while (1) { $seen{$video_id} = 1; get_and_play_video_ids($video_id) || return; my $related = $yv_obj->related_to_videoID($video_id); (my @video_ids = grep { not $seen{$_} } map { $yv_utils->get_video_id($_) } @{$related->{results}}) || return; $video_id = $opt{shuffle} ? $video_ids[rand @video_ids] : $video_ids[0]; } return 1; } sub play_videos { my ($videos) = @_; foreach my $video (@{$videos}) { my $video_id = $yv_utils->get_video_id($video); if ($opt{autoplay_mode}) { local $opt{autoplay_mode} = 0; autoplay($video_id); next; } # Ignore already watched videos if (exists($WATCHED_VIDEOS{$video_id}) and $opt{skip_watched}) { say ":: Already watched video (ID: $video_id)... Skipping..."; next; } if (defined($opt{max_seconds}) and $opt{max_seconds} >= 0) { next if $yv_utils->get_duration($video) > $opt{max_seconds}; } if (defined($opt{min_seconds}) and $opt{min_seconds} >= 0) { next if $yv_utils->get_duration($video) < $opt{min_seconds}; } my $streaming = get_streaming_url($video_id); if (ref($streaming->{streaming}) ne 'HASH') { warn colored("[!] No streaming URL has been found...", 'bold red') . "\n"; next; } if ( !defined($streaming->{streaming}{url}) and defined($streaming->{info}{status}) and $streaming->{info}{status} =~ /(?:error|fail)/i) { warn colored("[!] Error on: ", 'bold red') . sprintf($opt{youtube_video_url}, $video_id) . "\n"; warn colored(":: Reason: ", 'bold red') . $streaming->{info}{reason} =~ tr/+/ /r . "\n\n"; } # Dump metadata information if (defined($opt{dump})) { my $file = $video_id . '.' . $opt{dump}; open(my $fh, '>:utf8', $file) or die "Can't open file `$file' for writing: $!"; local $video->{streaming} = $streaming; if ($opt{dump} eq 'json') { require JSON; print {$fh} JSON->new->pretty(1)->encode($video); } elsif ($opt{dump} eq 'perl') { require Data::Dump; print {$fh} Data::Dump::pp($video); } close $fh; } if ($opt{download_video}) { print_video_info($video); if (not download_video($streaming, $video)) { return; } save_watched_video($video_id, $video); } elsif (length($opt{extract_info})) { my $fh = $opt{extract_info_fh} // \*STDOUT; say {$fh} $yv_utils->format_text( streaming => $streaming, info => $video, text => $opt{extract_info}, escape => $opt{escape_info}, fat32safe => $opt{fat32safe}, ); } else { print_video_info($video); my $command = get_player_command($streaming, $video); if ($yv_obj->get_debug) { say "-> Resolution: $streaming->{resolution}"; say "-> Video itag: $streaming->{streaming}{itag}"; say "-> Audio itag: $streaming->{streaming}{__AUDIO__}{itag}" if exists $streaming->{streaming}{__AUDIO__}; say "-> Video type: $streaming->{streaming}{type}"; say "-> Audio type: $streaming->{streaming}{__AUDIO__}{type}" if exists $streaming->{streaming}{__AUDIO__}; say "-> Command: $command"; } #<<< # Concept for playing videos with ffmpeg + ffplay # if (exists $streaming->{streaming}{__AUDIO__}) { # system("ffmpeg -i \Q$streaming->{streaming}{url}\E -i \Q$streaming->{streaming}{__AUDIO__}{url}\E -c:a copy -c:v copy -f matroska - | ffplay -"); # } # else { # system("ffplay", $streaming->{streaming}{url}); # } #>>> $yv_obj->proxy_system($command); # execute the video player if ($? and $? != 512) { $opt{auto_next_page} = 0; return; } save_watched_video($video_id, $video); } press_enter_to_continue() if $opt{confirm}; } return 1; } sub play_videos_matched_by_regex { my %args = @_; my $key = $args{key}; my $regex = $args{regex}; my $videos = $args{videos}; my $sub = \&{'WWW::PipeViewer::Utils' . '::' . 'get_' . $key}; if (not defined &$sub) { warn colored("\n[!] Invalid key: <$key>.", 'bold red') . "\n"; return; } if (defined(my $re = compile_regex($regex))) { if (my @nums = grep { $yv_utils->$sub($videos->[$_]) =~ /$re/ } 0 .. $#{$videos}) { if (not play_videos([@{$videos}[@nums]])) { return; } } else { warn colored("\n[!] No video <$key> matched by the regex: $re", 'bold red') . "\n"; return; } } return 1; } sub print_playlist_info { my ($playlist) = @_; my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width); printf( "\n%s\n%s\n%s\n", _bold_color('=> Description'), $hr, wrap_text( i_tab => q{}, s_tab => q{}, text => [$yv_utils->get_description($playlist) || 'No description available...'] ), ); my $id = $yv_utils->get_playlist_id($playlist); if ($id =~ m{^/}) { ## local playlist } else { say STDOUT $hr, "\n", _bold_color('=> URL: '), sprintf($opt{youtube_playlist_url}, $id); } my $title = $yv_utils->get_title($playlist); my $title_length = length($title); my $rep = ($term_width - $title_length) / 2 - 4; $rep = 0 if $rep < 0; print( "$hr\n", q{ } x $rep => (_bold_color("=>> $title <<=") . "\n\n"), ( map { sprintf(q{-> } . "%-*s: %s\n", $opt{_colors} ? 18 : 10, _bold_color($_->[0]), $_->[1]) } grep { defined($_->[1]) } ( ['Title' => $yv_utils->get_title($playlist)], ['Author' => $yv_utils->get_channel_title($playlist)], ['ChannelID' => $yv_utils->get_channel_id($playlist)], ['PlaylistID' => ($id =~ m{^/} ? undef : $id)], ['Videos' => $yv_utils->set_thousands($yv_utils->get_playlist_item_count($playlist))], ['Published' => $yv_utils->get_publication_date($playlist)], ) ), "$hr\n" ); return 1; } sub print_channel_info { my ($channel) = @_; my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width); printf( "\n%s\n%s\n%s\n%s\n%s", _bold_color('=> Description'), $hr, wrap_text( i_tab => q{}, s_tab => q{}, text => [$yv_utils->get_description($channel) || 'No description available...'] ), $hr, _bold_color('=> URL: ') ); print STDOUT sprintf($opt{youtube_channel_url}, $yv_utils->get_channel_id($channel)); my $title = $yv_utils->get_channel_title($channel); my $title_length = length($title); my $rep = ($term_width - $title_length) / 2 - 4; $rep = 0 if $rep < 0; print( "\n$hr\n", q{ } x $rep => (_bold_color("=>> $title <<=") . "\n\n"), ( map { sprintf(q{-> } . "%-*s: %s\n", $opt{_colors} ? 20 : 12, _bold_color($_->[0]), $_->[1]) } grep { defined($_->[1]) } ( ['Channel' => $title], ['ChannelID' => $yv_utils->get_channel_id($channel)], ['Videos' => $yv_utils->set_thousands($yv_utils->get_channel_video_count($channel))], ['Subscribers' => $yv_utils->set_thousands($yv_utils->get_channel_subscriber_count($channel))], ['Published' => $yv_utils->get_publication_date($channel)], ) ), "$hr\n" ); return 1; } sub print_video_info { my ($video) = @_; $opt{show_video_info} || return 1; my $extra_info = $yv_obj->video_details($yv_utils->get_video_id($video) // return 1); foreach my $key (keys %$extra_info) { $video->{$key} = $extra_info->{$key}; } my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width); printf( "\n%s\n%s\n%s\n%s\n%s", _bold_color('=> Description'), $hr, wrap_text( i_tab => q{}, s_tab => q{}, text => [$yv_utils->get_description($video) || 'No description available...'] ), $hr, _bold_color('=> URL: ') ); print STDOUT sprintf($opt{youtube_video_url}, $yv_utils->get_video_id($video)); my $title = $yv_utils->get_title($video); my $title_length = length($title); my $rep = ($term_width - $title_length) / 2 - 4; $rep = 0 if $rep < 0; my $likes = $yv_utils->get_likes($video); my $views = $yv_utils->get_views($video); print( "\n$hr\n", q{ } x $rep => (_bold_color("=>> $title <<=") . "\n\n"), ( map { sprintf(q{-> } . "%-*s: %s\n", $opt{_colors} ? 18 : 10, _bold_color($_->[0]), $_->[1]) } grep { defined($_->[1]) } ( ['Channel' => $yv_utils->get_channel_title($video)], ['ChannelID' => $yv_utils->get_channel_id($video)], ['VideoID' => $yv_utils->get_video_id($video)], ['Category' => $yv_utils->get_category_name($video)], ['Duration' => $yv_utils->get_time($video)], ['Likes' => $yv_utils->set_thousands($likes)], ['Rating' => $yv_utils->get_rating($video)], ['Views' => $yv_utils->set_thousands($views)], ['Published' => $yv_utils->get_publication_date($video)], ) ), "$hr\n" ); return 1; } sub print_videos { my ($results, %args) = @_; if (not $yv_utils->has_entries($results)) { warn_no_results("video"); } if ($opt{get_term_width}) { get_term_width(); } my $url = $results->{url}; my $videos = $results->{results} // []; if (ref($videos) eq 'HASH' and exists $videos->{videos}) { $videos = $videos->{videos}; } if (ref($videos) eq 'HASH' and exists $videos->{entries}) { $videos = $videos->{entries}; } my $token = undef; if (ref($results->{results}) eq 'HASH' and exists $results->{results}{continuation}) { $token = $results->{results}{continuation}; } if (ref($videos) ne 'ARRAY') { say "\n:: Probably the selected invidious instance is down. Try:"; say "\n\t$0 --api=auto\n"; say "See also: https://github.com/trizen/pipe-viewer#invidious-instances"; return; } if ($opt{shuffle}) { require List::Util; $videos = [List::Util::shuffle(@{$videos})]; } my @formatted; foreach my $i (0 .. $#{$videos}) { my $video = $videos->[$i]; my $entry = $opt{custom_layout_format}; push @formatted, format_line_result($i, $entry, $video); } if ($opt{highlight_watched}) { foreach my $i (0 .. $#{$videos}) { my $video = $videos->[$i]; if (exists($WATCHED_VIDEOS{$yv_utils->get_video_id($video)})) { $formatted[$i] = colored(colorstrip($formatted[$i]), $opt{highlight_color}); } } } if (@formatted) { print "\n" . join("", @formatted); } if ($opt{play_all} || $opt{play_backwards}) { if (@{$videos}) { if ( play_videos( $opt{play_backwards} ? [reverse @{$videos}] : $videos ) ) { if ($opt{play_backwards}) { if (defined($url)) { return; } else { $opt{play_backwards} = 0; warn_first_page(); return; } } else { if (defined($url) or ref($token) eq 'CODE') { __SUB__->($yv_obj->next_page($url, $token), auto => 1); } else { $opt{play_all} = 0; warn_last_page(); return; } } } else { $opt{play_all} = 0; $opt{play_backwards} = 0; __SUB__->($results); } } else { $opt{play_all} = 0; $opt{play_backwards} = 0; } } state @keywords; if ($args{auto}) { } # do nothing... else { @keywords = get_input_for_search(); if (scalar(@keywords) == 0) { # only arguments __SUB__->($results); } } state @for_search; state @for_play; my @copy_of_keywords = @keywords; my $contains_keywords = grep { /$non_digit_or_opt_re/ } @keywords; while (@keywords) { my $key = shift @keywords; if ($key =~ /$valid_opt_re/) { my $opt = $1; if ( general_options(opt => $opt, res => $videos,) ) { ## ok } elsif ($opt =~ /^(?:h|help)\z/) { print $complete_help; press_enter_to_continue(); } elsif ($opt =~ /^(?:n|next)\z/) { if (defined($url) or ref($token) eq 'CODE') { my $request = $yv_obj->next_page($url, $token); __SUB__->($request, @keywords ? (auto => 1) : ()); } else { warn_last_page(); if ($opt{auto_next_page}) { $opt{auto_next_page} = 0; @copy_of_keywords = (); last; } } } # :refresh elsif ($opt =~ /^(?:R|refresh)\z/) { ##@{$videos} = @{$yv_obj->_get_results($url)->{results}}; } # :r, :return elsif ($opt =~ /^(?:r|return)\z/) { return; } # :author=i, :u=i elsif ($opt =~ /^(?:a|author|u|uploads)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $request = $yv_obj->uploads($channel_id); if ($yv_utils->has_entries($request)) { __SUB__->($request); } else { warn_no_results('video'); } } } else { warn_no_thing_selected('video'); } } # :streams=i, :us=i # :shorts=i elsif ($opt =~ /^(streams|us|shorts)${digit_or_equal_re}(.*)/) { my $type = $1; if (my @nums = get_valid_numbers($#{$videos}, $2)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $request = ($type =~ /shorts/) ? $yv_obj->shorts($channel_id) : $yv_obj->streams($channel_id); if ($yv_utils->has_entries($request)) { __SUB__->($request); } else { warn_no_results($type =~ /shorts/ ? 'shorts' : 'streams'); } } } else { warn_no_thing_selected('video'); } } # :s=i, :subscribe=i elsif ($opt =~ /^(?:s|sub(?:scribe)?)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $channel_title = $yv_utils->get_channel_title($videos->[$id]); subscribe_channel($channel_id, $channel_title); } } else { warn_no_thing_selected('video'); } } # :save=i elsif ($opt =~ /^(?:save)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $channel_title = $yv_utils->get_channel_title($videos->[$id]); save_channel($channel_id, $channel_title); } } else { warn_no_thing_selected('video'); } } # :pv=i, :popular=i elsif ($opt =~ /^(?:pv|popular)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $request = $yv_obj->popular_videos($channel_id); if ($yv_utils->has_entries($request)) { __SUB__->($request); } else { warn_no_results('popular video'); } } } else { warn_no_thing_selected('video'); } } # :ps=i, :pstreams=i, :popular-streams=i # :pshorts=i, :popular-shorts=i elsif ($opt =~ /^(ps|pstreams|popular-streams|pshorts|popular-shorts)${digit_or_equal_re}(.*)/) { my $type = $1; if (my @nums = get_valid_numbers($#{$videos}, $2)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $request = ($type =~ /shorts/) ? $yv_obj->popular_shorts($channel_id) : $yv_obj->popular_streams($channel_id); if ($yv_utils->has_entries($request)) { __SUB__->($request); } else { warn_no_results('popular ' . ($type =~ /shorts/ ? 'shorts' : 'streams')); } } } else { warn_no_thing_selected('video'); } } # :p=i, :playlist=i, :up=i elsif ($opt =~ /^(?:p|l|playlists?|up)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $request = $yv_obj->playlists($yv_utils->get_channel_id($videos->[$id])); if ($yv_utils->has_entries($request)) { print_playlists($request); } else { warn_no_results('playlist'); } } } else { warn_no_thing_selected('video'); } } # :like=i, :dislike=i elsif ($opt =~ /^((?:dis)?like)${digit_or_equal_re}(.*)/) { my $rating = $1; if (my @nums = get_valid_numbers($#{$videos}, $2)) { rate_videos($rating, map { $videos->[$_] } @nums); } else { warn_no_thing_selected('video'); } } # :fav=i, :favorite=i elsif ($opt =~ /^(?:fav|favorite|F)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { favorite_videos(map { $videos->[$_] } @nums); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:q|queue|enqueue)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { push @{$opt{_queue_play}}, map { $yv_utils->get_video_id($videos->[$_]) } @nums; } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:pq|qp|play-queue)\z/) { if (ref $opt{_queue_play} eq 'ARRAY' and @{$opt{_queue_play}}) { my $ids = 'v=' . join(q{,}, splice @{$opt{_queue_play}}); general_options(opt => $ids); } else { warn colored("\n[!] The playlist is empty!", 'bold red') . "\n"; } } elsif ($opt =~ /^c(?:omments?)?${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { get_and_print_comments(map { $yv_utils->get_video_id($videos->[$_]) } @nums); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^r(?:elated)?${digit_or_equal_re}(.*)/) { if (my ($id) = get_valid_numbers($#{$videos}, $1)) { get_and_print_related_videos($yv_utils->get_video_id($videos->[$id])); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:w|mark)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { save_watched_video($yv_utils->get_video_id($videos->[$_]), $videos->[$_]) for @nums; } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:ap|autoplay)${digit_or_equal_re}(.*)/) { if (my ($id) = get_valid_numbers($#{$videos}, $1)) { local $opt{autoplay_mode} = 1; play_videos([$videos->[$id]]); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^d(?:ownload)?${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { local $opt{download_video} = 1; play_videos([@{$videos}[@nums]]); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:play|P)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { local $opt{download_video} = 0; local $opt{extract_info} = undef; play_videos([@{$videos}[@nums]]); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^i(?:nfo)?${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $num (@nums) { local $opt{show_video_info} = 1; print_video_info($videos->[$num]); } press_enter_to_continue(); } else { warn_no_thing_selected('video'); } } elsif ($opt eq 'anp') { # auto-next-page $opt{auto_next_page} = 1; } elsif ($opt eq 'nnp') { # no-next-page $opt{auto_next_page} = 0; } elsif ($opt =~ /^[ks]re(?:gex)?=(.*)/) { my $value = $1; if ($value =~ /^([a-zA-Z]++)(?>,|=>)(.+)/) { play_videos_matched_by_regex( key => $1, regex => $2, videos => $videos, ) or __SUB__->($results); } else { warn_invalid("Special Regexp", $value); } } elsif ($opt =~ /^re(?:gex)?=(.*)/) { play_videos_matched_by_regex( key => 'title', regex => $1, videos => $videos, ) or __SUB__->($results); } else { warn_invalid('option', $opt); } } elsif (youtube_urls($key)) { ## ok } elsif (!$contains_keywords and (valid_num($key, $videos) or $key =~ /$range_num_re/)) { my @for_play; if ($key =~ /$range_num_re/) { my $from = $1; my $to = $2 // do { $opt{auto_next_page} ? do { $from = 1 } : do { $opt{auto_next_page} = 1 }; $#{$videos} + 1; }; my @ids = get_valid_numbers($#{$videos}, "$from..$to"); if (@ids) { push @for_play, @ids; } else { push @for_search, $key; } } else { push @for_play, $key - 1; } if (@for_play and not play_videos([@{$videos}[@for_play]])) { __SUB__->($results); } } else { push @for_search, $key; } } if (@for_search) { __SUB__->($yv_obj->search_videos([splice(@for_search)])); } elsif ($opt{auto_next_page}) { @keywords = (':next', grep { $_ !~ /^:(n|next|anp)\z/ } @copy_of_keywords); if (@keywords > 1) { my $timeout = 2; print colored("\n:: Press in $timeout seconds to stop the :anp option.", 'bold green'); eval { local $SIG{ALRM} = sub { die "alarm\n"; }; alarm $timeout; scalar ; alarm 0; }; if ($@) { if ($@ eq "alarm\n") { __SUB__->($results, auto => 1); } else { warn colored("\n[!] Unexpected error: <$@>.", 'bold red') . "\n"; } } else { $opt{auto_next_page} = 0; __SUB__->($results); } } else { warn colored("\n[!] Option ':anp' works only combined with other options!", 'bold red') . "\n"; $opt{auto_next_page} = 0; __SUB__->($results); } } __SUB__->($results) if not $args{auto}; return 1; } sub press_enter_to_continue { say ''; scalar $term->readline(colored("=>> Press ENTER to continue...", 'bold')); } sub main_quit { exit($_[0] // 0); } main_quit(0); =head1 CONFIGURATION OPTIONS =head2 api_host Hostname of an invidious instance. When set to C<"auto">, a random invidious instance is selected on-demand. List of public invidious instances: https://api.invidious.io/ Tor instances are also supported if the C Tor proxy is available and the Perl module L is installed. =head2 auto_captions When set to C<1>, auto-generated captions will be retrieved. By default, auto-generated captions are ignored. =head2 audio_quality The preferred quality for the audio-track: best # best audio quality available (<=192kbps) medium # medium audio quality (<=128kbps) low # low audio quality (<=50kbps) The option can also be set to a numeric value N in order to select an audio-track with <= Nkbps. =head2 autoplay_mode Enable autoplay mode, which will continuously play related videos. =head2 bypass_age_gate_native Bypass age-restricted videos, using our internal method. However, these streaming URLs are heavily throttled by YouTube. When this option is disabled, a fallback method will be used instead (C/C or invidious instances). =head2 bypass_age_gate_with_proxy Bypass age-restricted videos using an YouTube Account Proxy. =head2 cache_dir Cache directory where to save temporary files. =head2 colors Use colors for text. =head2 comments_order The sorting order for comments. Valid values: "top", "new". =head2 confirm Display a confirmation message after each video played. =head2 convert_cmd Command to convert videos. Default value: "ffmpeg -i *IN* *OUT*" B<*IN*> gets replaced with the input file. B<*OUT*> gets replaced with the output file. =head2 convert_to Format to convert each downloaded video into. (e.g.: C<"mp3">). =head2 cookie_file Load cookies from a file. Useful to overcome the "429: Too Many Requests" issue. The file must be a C<# Netscape HTTP Cookie File>. Same format as C requires. See also: https://github.com/ytdl-org/youtube-dl#how-do-i-pass-cookies-to-youtube-dl =head2 copy_caption When downloading a video, copy the closed-caption (if any) into the same folder with the video. If C and C are both enabled, there is no need to enable this option. =head2 custom_layout_format An array of hash values specifying a custom layout for video results. align # "left" or "right" color # any color name supported by Term::ANSIColor text # the actual text width # width allocated for the text The value for C can be either a number of characters (e.g.: 20) or can be a percentage of the terminal width (e.g.: "15%"). The special tokens for C are listed in: pipe-viewer --tricks For better formatting, it's highly recommended to install L or L. =head2 custom_channel_layout_format An array of hash values specifying a custom layout for channel results. =head2 custom_playlist_layout_format An array of hash values specifying a custom layout for playlist results. =head2 dash Include or exclude streams in "Dynamic Adaptive Streaming over HTTP" (DASH) format. =head2 date Search for videos uploaded within a specific amount of time. Valid values: "anytime", "hour", "today", "week", "month", "year". =head2 debug Enable debug/verbose mode, which will print some extra information. Valid values: 0, 1, 2, 3. =head2 downloads_dir Directory where to download files and where to save converted files. =head2 download_and_play Play downloaded videos. =head2 download_in_subdir Download videos in a new subdirectory inside the C parent directory. When enabled, videos will be saved inside a subdirectory specified by C. =head2 download_in_subdir_format Format string used for creating the subdirectory where to download the files, when C is enabled. The available special tokens are listed in: pipe-viewer --tricks =head2 download_with_wget Download videos with C. =head2 download_with_ytdl Download videos with `yt-dlp` or `youtube-dl`. By enabling this option and setting the value of C to C<"yt-dlp">, download speed will be greatly improved. =head2 env_proxy Load proxy settings from C<*_proxy> environment variables (if any). =head2 fat32safe When downloading a video, make the filename compatible with the FAT32 filesystem. Additionally, if L is available, then Unicode characters are converted to ASCII equivalents. =head2 features A list of video features, return only videos with the specified features: live # Live stream 4k # 4K resolution hd # HD resolution (>=720p) subtitles # Video has subtitles/closed-captions creative_commons # Creative Commons license 360 # 360° field of view vr180 # Stereoscopic widh a 180° field of view 3d # 3D hdr # High dynamic range =head2 ffmpeg_cmd Path to the C program. =head2 force_fallback Force the extraction of the streaming URLs to always use the fallback method (youtube-dl / invidious). =head2 fullscreen Play videos in fullscreen mode. =head2 get_captions Download closed-captions for videos (if any). =head2 get_term_width Read the terminal width (`stty size`). =head2 hfr Prefer or ignore High Frame Rate (HFR) video streams. Try to disable this option if the videos are lagging or dropping frames. =head2 highlight_color Highlight color used to highlight watched videos. Any color name supported by L can be used. =head2 highlight_watched Highlight watched videos. =head2 history Enable or disable support for input history. Requires L. =head2 history_file File where to save the input history. =head2 history_limit Maximum number of entries in the history file. When the limit is reached, the oldest half of the history file will be deleted. For no limit, set the value to C<-1>. =head2 http_proxy Set HTTP(S)/SOCKS proxy, using the format: 'proto://domain.tld:port/' If authentication is required, use: 'proto://user:pass@domain.tld:port/' For example, to use Tor, install L and set this value to: "socks://127.0.0.1:9050" =head2 ignore_av1 Ignore videos in AV1 format. =head2 ignored_projections An array of video projections to ignore. For example, to prefer rectangular projections of 360° videos, use: ignored_projections => ["mesh", "equirectangular"], =head2 interactive Interactive mode, prompting for user-input. =head2 keep_original_video Keep the original video after conversion. When set to C<0>, the original video will be deleted. =head2 local_playlist_limit When set to a positive value, will restrict the size of the local playlists to this many entries. When this limit has been reached, older entries will be discarded from the playlist. A reasonable value would be between 500 and 1000. For no limit, set the value to C<-1>. =head2 maxResults How many results to display per page. Currently, this is not implemented. =head2 merge_into_mkv When downloading split videos, merge the audio+video files into an MKV container. Requires C. =head2 merge_into_mkv_args Arguments for C how to merge the files. =head2 merge_with_captions Include closed-captions inside the MKV container (if any). =head2 order Search order for videos. Valid values: "relevance", "rating", "upload_date", "view_count". =head2 page Page number of results. =head2 prefer_av1 Prefer videos in AV1 format. (experimental) =head2 prefer_mp4 Prefer videos in MP4 (AVC) format. Try to enable this option if the videos are lagging or dropping frames. =head2 prefer_m4a Prefer audio streams in M4A (AAC) format. By default, the OPUS format for audio is preferred. =head2 prefer_invidious Prefer invidious instances over parsing the YouTube website directly. =head2 region ISO 3166 country code (default: "US"). =head2 remove_played_file When C is enabled, remove the file after playing it. =head2 resolution Preferred resolution for videos. Valid values: best, 2160p, 1440p, 1080p, 720p, 480p, 360p, 240p, 144p, audio. =head2 show_video_info Show extra info for videos when selected. =head2 skip_if_exists When downloading, skip if the file already exists locally. =head2 skip_watched Skip already watched/downloaded videos. =head2 split_videos Enable or disable support for split-videos. Split-videos are videos that do not include audio and video in the same file. Disable this option if the videos are loading too slowly, as non-split videos are not throttled by YouTube. Although, the highest resolution of non-split videos is 360p. =head2 srt_languages List of SRT languages in the order of preference. =head2 saved_channels_file Absolute path to the file where to store saved channels (C<:save=i>). =head2 set_mtime When enabled, it will set the original modification date of a downloaded video, using the published date of the video. =head2 subscribed_channels_file Absolute path to the file where to store subscribed channels (C<:sub=i>). =head2 subscription_results Comma-separated list of methods used to retrieve videos from subscribed channels. subscription_results => "uploads,streams", # include streams in feed Valid values: uploads, streams, shorts. =head2 subscriptions_lifetime Amount of time, in seconds, before rescanning the subscribed channels for new videos (during C<-ls>). =head2 subscriptions_limit Maximum number of subscription videos to store in the local database. Set to C<0> for no limit. =head2 thousand_separator Thousands separator character for numbers >= 1000. =head2 timeout HTTPS timeout value in seconds. The default value is 10 seconds. =head2 user_agent Token that is used to identify the user agent on the network. The agent value is sent as the C header in the requests. =head2 video_filename_format Format string used for creating the filename of downloaded videos. The available special tokens are listed in: pipe-viewer --tricks =head2 video_player_selected The selected video player defined in the C table. =head2 video_players A table of video players. The keys for each player are: arg # any arguments for the video player audio # option specifying the *AUDIO* file cmd # the main player command fs # the fullscreen option novideo # the no-video mode option srt # option specifying the *SUB* file =head2 videoDuration Retrieve only short or long videos in search results. Valid values: "any", "short" (under 4 minutes), "average" (4-20 minutes), and "long" (over 20 minutes). =head2 watch_history Set to C<1> to remember and highlight watched videos across multiple sessions. Watched videos can be listed with: pipe-viewer -wv The video IDs are saved in the filename specified by C. =head2 watch_history_file File where to save the video IDs of watched/downloaded videos when C is set to a true value. =head2 wget_cmd Command for C when C is set to a true value. =head2 youtube_video_url Format for C for constructing an YouTube video URL given the video ID. =head2 youtube_channel_url Format for C for constructing an YouTube channel URL given the channel ID. =head2 youtube_playlist_url Format for C for constructing an YouTube playlist URL given the playlist ID. =head2 ytdl Use C for videos with encrypted signatures. When set to C<0>, invidious instances will be used instead. =head2 ytdl_cmd Command for C or C when C is set to C<1>. Also command for C for extracting YouTube comments when C is set to C<1>. =head2 ytdlp_comments When set to C<1>, use C for extracting YouTube comments. (experimental) When set to C<0>, invidious instances will be used instead. =head2 ytdlp_max_comments Maximum number of comments to extract with C. Replies count as comments. =head2 ytdlp_max_replies Maximum number of replies per thread. Use C<0> to disable replies. Use C<"all"> to extract all replies. =head1 CONFIGURATION FILES The configuration files are: ~/.config/pipe-viewer/pipe-viewer.conf ~/.config/pipe-viewer/gtk-pipe-viewer.conf =head1 INVIDIOUS API REFERENCE https://github.com/iv-org/invidious/wiki/API =head1 REPOSITORY https://github.com/trizen/pipe-viewer =head1 LICENSE AND COPYRIGHT Copyright 2010-2025 Trizen. This program is free software; you can redistribute it and/or modify it under the terms of either: the GNU General Public License as published by the Free Software Foundation; or the Artistic License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See L for more information. =cut