aboutsummaryrefslogtreecommitdiffstats
path: root/bin/fair-viewer
diff options
context:
space:
mode:
Diffstat (limited to 'bin/fair-viewer')
-rwxr-xr-xbin/fair-viewer4183
1 files changed, 4183 insertions, 0 deletions
diff --git a/bin/fair-viewer b/bin/fair-viewer
new file mode 100755
index 0000000..525d2bb
--- /dev/null
+++ b/bin/fair-viewer
@@ -0,0 +1,4183 @@
+#!/usr/bin/perl
+
+# Copyright (C) 2010-2020 Trizen <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d>.
+#
+# 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.
+#
+#-------------------------------------------------------
+# fair-viewer
+# Fork: 14 February 2020
+# Edit: 14 February 2020
+# https://framagit.org/heckyel/fair-viewer
+#-------------------------------------------------------
+
+# fair-viewer is a command line utility for streaming YouTube videos in mpv/vlc/mplayer.
+
+# This is a fork of youtube-viewer:
+# https://github.com/trizen/youtube-viewer
+
+=head1 NAME
+
+fair-viewer - YouTube from command line.
+
+See: fair-viewer --help
+ fair-viewer --tricks
+ fair-viewer --examples
+ fair-viewer --stdin-help
+
+=head1 LICENSE AND COPYRIGHT
+
+Copyright 2010-2020 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<https://dev.perl.org/licenses/> for more information.
+
+=cut
+
+use utf8;
+use 5.016;
+
+use warnings;
+no warnings 'once';
+
+my $DEVEL; # true in devel mode
+use if ($DEVEL = 1), lib => qw(../lib); # devel mode
+
+use WWW::FairViewer v0.0.1;
+use WWW::FairViewer::RegularExpressions;
+
+use File::Spec::Functions qw(
+ catdir
+ catfile
+ curdir
+ path
+ rel2abs
+ tmpdir
+ file_name_is_absolute
+ );
+
+binmode(STDOUT, ':utf8');
+
+my $appname = 'CLI Fair Viewer';
+my $version = $WWW::FairViewer::VERSION;
+my $execname = 'fair-viewer';
+
+# A better <STDIN> support:
+require Term::ReadLine;
+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 => ($^O eq 'MSWin32' ? 1 : 0)); # doh
+
+my $home_dir;
+my $xdg_config_home = $ENV{XDG_CONFIG_HOME};
+
+if ($xdg_config_home and -d -w $xdg_config_home) {
+ require File::Basename;
+ $home_dir = File::Basename::dirname($xdg_config_home);
+
+ if (not -d -w $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 -w $home_dir) {
+ $home_dir = curdir();
+ }
+
+ $xdg_config_home = catdir($home_dir, '.config');
+}
+
+# Configuration dir/file
+my $config_dir = catdir($xdg_config_home, $execname);
+my $config_file = catfile($config_dir, "$execname.conf");
+my $authentication_file = catfile($config_dir, 'reg.dat');
+my $history_file = catfile($config_dir, 'cli-history.txt');
+my $watched_file = catfile($config_dir, 'watched.txt');
+my $api_file = catfile($config_dir, 'api.json');
+
+if (not -d $config_dir) {
+ require File::Path;
+ File::Path::make_path($config_dir)
+ or warn "[!] Can't create dir '$config_dir': $!";
+}
+
+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 => {
+ 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*},
+ 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 --title=*TITLE* --no-ytdl},
+ novideo => q{--no-video},
+ },
+ mplayer => {
+ cmd => q{mplayer},
+ srt => q{-sub *SUB*},
+ audio => q{-audiofile *AUDIO*},
+ fs => q{-fs},
+ arg => q{-prefer-ipv4 -really-quiet -title *TITLE*},
+ novideo => q{-novideo},
+ },
+ },
+
+ video_player_selected => (
+ $constant{win32}
+ ? 'mplayer'
+ : undef # auto-defined
+ ),
+
+ # YouTube options
+ dash_support => 1,
+ dash_mp4_audio => 1,
+ dash_segmented => 1, # may load slow
+ maxResults => 20,
+ resolution => 'best',
+ videoDefinition => undef,
+ videoDimension => undef,
+ videoLicense => undef,
+ safeSearch => undef,
+ videoCaption => undef,
+ videoDuration => undef,
+ videoSyndicated => undef,
+ publishedBefore => undef,
+ publishedAfter => undef,
+ order => undef,
+
+ comments_order => 'top', # valid values: top, new
+ subscriptions_order => 'relevance', # valid values: alphabetical, relevance, unread
+
+ hl => 'en_US',
+ regionCode => undef,
+
+ # URI options
+ youtube_video_url => 'https://www.youtube.com/watch?v=%s',
+
+ # Subtitle options
+ srt_languages => ['en', 'es'],
+ get_captions => 1,
+ auto_captions => 0,
+ copy_caption => 0,
+ captions_dir => catdir(tmpdir(), 'fair-viewer'),
+ cache_dir => catdir(tmpdir(), 'fair-viewer'),
+
+ # API
+ api_host => "https://invidio.us",
+
+ # Others
+ autoplay_mode => 0,
+ http_proxy => undef,
+ env_proxy => 1,
+ confirm => 0,
+ debug => 0,
+ page => 1,
+ colors => $constant{win32} ^ 1,
+ skip_if_exists => 1,
+ prefer_mp4 => 0,
+ prefer_av1 => 0,
+ fat32safe => $constant{win32},
+ fullscreen => 0,
+ results_with_details => 0,
+ results_with_colors => 0,
+ results_fixed_width => undef, # auto-defined
+ show_video_info => 1,
+ interactive => 1,
+ get_term_width => $constant{win32} ^ 1,
+ download_with_wget => undef, # auto-defined
+ thousand_separator => q{,},
+ downloads_dir => curdir(),
+ keep_original_video => 0,
+ download_and_play => 0,
+ autohide_watched => 0,
+ skip_watched => 0,
+ remember_watched => 0,
+ watched_file => $watched_file,
+ highlight_watched => 1,
+ highlight_color => 'bold',
+ remove_played_file => 0,
+ history => undef, # auto-defined
+ history_limit => 100_000,
+ history_file => $history_file,
+ convert_cmd => 'ffmpeg -i *IN* *OUT*',
+ convert_to => undef,
+
+ custom_layout => undef, # auto-defined
+ 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 => "yellow", 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*",},
+ ],
+
+ 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,
+
+ video_filename_format => '*FTITLE* - *ID*.*FORMAT*',
+);
+
+local $SIG{__WARN__} = sub { warn @_; ++$opt{_error} };
+
+my %MPLAYER; # 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 $action_options = <<'ACTIONS';
+# Actions
+:login : will prompt you for login
+:logout : will delete the authentication key
+ACTIONS
+
+my $control_options = <<'CONTROL';
+# Control
+:n(ext) : get the next page of results
+:b(ack) : get the previous page of results
+CONTROL
+
+my $other_options = <<'OTHER';
+# Others
+:r(eturn) : return to previous page of results
+: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)
+:reset, :reload : restart the application
+: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";
+
+$action_options
+$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 = <<"PL_HELP" . $general_help;
+
+# Playlists
+:pp=i,i : play videos from the selected playlists
+PL_HELP
+
+my $comments_help = <<"COM_HELP" . $general_help;
+
+# Comments
+:c(omment) : send a comment to this video
+COM_HELP
+
+my $complete_help = <<"STDIN_HELP";
+
+$base_options
+$control_options
+$action_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
+:pv=i :popular=i : display author's popular uploads
+:A(ctivity)=i : display author's recent activity
+:p(laylists)=i : display author's playlists
+:ps=i :s2p=i,i : save videos to a post-selected playlist
+:subscribe=i : subscribe to author's channel
+:(dis)like=i : like or dislike a video
+:fav(orite)=i : favorite a video
+:autoplay=i : autoplay mode, starting from video i
+
+# Playing
+<number> : 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=my?[regex] : 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
+
+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 ($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;
+
+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!";
+ }
+
+ # Get valid config keys
+ my @valid_keys = grep { exists $CONFIG{$_} } keys %{$CONFIG};
+ @CONFIG{@valid_keys} = @{$CONFIG}{@valid_keys};
+
+ my $update_config = 0;
+
+ # Define the cache directory
+ if (not defined $CONFIG{cache_dir}) {
+
+ my $cache_dir =
+ ($ENV{XDG_CACHE_HOME} and -d -w $ENV{XDG_CACHE_HOME})
+ ? $ENV{XDG_CACHE_HOME}
+ : catdir($home_dir, '.cache');
+
+ if (not -d -w $cache_dir) {
+ $cache_dir = catdir(curdir(), '.cache');
+ }
+
+ $CONFIG{cache_dir} = catdir($cache_dir, 'youtube-viewer');
+ $update_config = 1;
+ }
+
+ # Locating a 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';
+ }
+ }
+ elsif ($CONFIG{video_player_selected} =~ /mpv/i) { # update for mpv 0.32 (#290)
+#<<<
+ my $mpv = $CONFIG{video_players}{$CONFIG{video_player_selected}};
+ if (
+ ($mpv->{arg} =~ s/(--title)\s+(\*TITLE\*)/$1=$2/g)
+ | ($mpv->{audio} =~ s/(--audio-file)\s+(\*AUDIO\*)/$1=$2/g)
+ | ($mpv->{srt} =~ s/(--sub-file)\s+(\*SUB\*)/$1=$2/g)
+ ) {
+ say ":: Updated configuration to support mpv 0.32";
+ $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;
+ }
+
+ # Fixed-width format and custom layout
+ if ( not defined($CONFIG{results_fixed_width})
+ or not defined($CONFIG{custom_layout})) {
+
+ if ( eval { require Unicode::GCString; 1 }
+ or eval { require Text::CharWidth; 1 }) {
+ $CONFIG{custom_layout} //= 1;
+ $CONFIG{results_fixed_width} //= 1;
+ }
+ else {
+ $CONFIG{custom_layout} = 0;
+ $CONFIG{results_fixed_width} = 0;
+ }
+
+ $update_config = 1;
+ }
+
+ # Enable history if Term::ReadLine::Gnu::XS is installed
+ if (not defined $CONFIG{history}) {
+
+ if ($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;
+
+foreach my $path($CONFIG{cache_dir}, $CONFIG{captions_dir}) {
+ next if -d $path;
+ require File::Path;
+ File::Path::make_path($path)
+ or warn "[!] Can't create path <<$path>>: $!";
+}
+
+ @opt{keys %CONFIG} = values(%CONFIG);
+}
+
+load_config($config_file);
+
+if ($opt{remember_watched}) {
+ if (-f $opt{watched_file}) {
+ if (open my $fh, '<', $opt{watched_file}) {
+ chomp(my @ids = <$fh>);
+ @watched_videos{@ids} = ();
+ close $fh;
+ }
+ else {
+ warn "[!] Can't open the watched file `$opt{watched_file}' for reading: $!";
+ }
+ }
+}
+
+if ($opt{history}) {
+
+ # Create the history file.
+ if (not -e $opt{history_file}) {
+ open my $fh, '>', $opt{history_file}
+ or warn "[!] Can't create the history file `$opt{history_file}': $!";
+ }
+
+ # Add history to Term::ReadLine
+ $term->ReadHistory($opt{history_file});
+
+ # All history entries
+ my @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::FairViewer->new(
+ escape_utf8 => 1,
+ config_dir => $config_dir,
+ cache_dir => $opt{cache_dir},
+ lwp_env_proxy => $opt{env_proxy},
+ authentication_file => $authentication_file,
+ );
+
+require WWW::FairViewer::Utils;
+my $yv_utils = WWW::FairViewer::Utils->new(youtube_url_format => $opt{youtube_video_url},
+ thousand_separator => $opt{thousand_separator},);
+
+{ # Apply the configuration file
+ my %temp = %CONFIG;
+ apply_configuration(\%temp);
+}
+
+#---------------------- YOUTUBE-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
+ -hl --catlang=s : language for categories (default: en_US)
+
+ * Region
+ --region=s : set the region code (default: US)
+
+ * Videos
+ -uv --uploads=s : list videos uploaded by a specific channel or user
+ -pv --popular=s : list the most popular videos from a specific channel
+ -uf --favorites=s : list the videos favorited by a specific user
+ -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)
+
+ * Playlists
+ -up --playlists=s : list playlists created by a specific channel or user
+ -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
+ --ps=s : add video by ID or URL to a post-selected playlist
+ or in a given playlist ID specified with `--pid`
+ --position=i : position in the playlist where to add the video
+
+* Activities
+ -ua --activities:s : show activity events for a given channel
+
+* Trending
+ --trending:s : show trending videos in a given category ID or name
+ use the `--within=s` option to restrict the results
+
+ * Channels
+ -sc --channels : search for YouTube channels
+
+* 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
+
+ * Filtering
+ --author=s : search in videos uploaded by a specific user
+ --duration=s : filter search results based on video length
+ valid values are: short medium long
+ --caption=s : only videos with/without closed captions
+ valid values are: any closedCaption none
+ --category=s : search only for videos in a specific category name/ID
+ --safe-search=s : YouTube will skip restricted videos for your location
+ valid values are: none moderate strict
+ --order=s : order the results using a specific sorting method
+ valid values: date rating viewCount title videoCount
+ --within=s : show only videos uploaded within the specified time
+ valid values are: Nd, Nw, Nm, Ny, where N is a number
+ --hd! : search only for videos available in at least 720p
+ --vd=s : set the video definition (any, high or standard)
+ --page=i : get results starting with a specific page number
+ --results=i : how many results to display per page (max: 50)
+ -2 -3 -4 -7 -1 : resolutions: 240p, 360p, 480p, 720p and 1080p
+ --resolution=s : supported resolutions: best, 2160p, 1440p,
+ 1080p, 720p, 480p, 360p, 240p, 144p, audio.
+
+ * Account
+ --login : will prompt for authentication (OAuth 2.0)
+ --logout : will delete the authentication key
+
+ * [GET] Personal
+ -U --uploads:s : show the uploads from your channel *
+ -P --playlists:s : show the playlists from your channel *
+ -F --favorites:s : show the latest favorited videos *
+ -S --subscriptions:s : show the subscribed channels *
+ -SV --subs-videos:s : show the subscription videos (slow) *
+ --subs-order=s : change the subscription order
+ valid values: alphabetical, relevance, unread
+ -L --likes : show the videos that you liked on YouTube *
+ --dislikes : show the videos that you disliked on YouTube *
+
+* [POST] Personal
+ --subscribe=s : subscribe to a given channel ID or username *
+ --favorite=s : favorite a video by URL or ID *
+ --like=s : send a 'like' rating to a video URL or ID *
+ --dislike=s : send a 'dislike' rating to a video URL or ID *
+
+
+== Player Options ==
+
+ * Arguments
+ -f --fullscreen! : play videos in fullscreen mode
+ -n --audio! : 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)
+ --wget-dl! : download videos with wget (recommended)
+ --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 (includes Unicode)
+ --mkv-merge! : merge audio and video into an MKV container
+ --merge-captions! : include closed-captions in the MKV container
+
+ * 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
+ --autohide! : automatically hide watched videos
+ --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 WEBM
+ --prefer-av1! : prefer videos in AV1 format, instead of WEBM
+
+ * Closed-captions
+ --get-captions! : download closed-captions for videos
+ --auto-captions! : include or exclude auto-generated captions
+ --captions-dir=s : directory where to save the .srt files
+
+ * Config
+ --config=s : configuration file
+ --update-config! : update the configuration file
+
+ * Output
+ -C --colorful! : use colors to delimit the video results
+ -D --details! : display the results with extra details
+ -W --fixed-width! : adjust the results to fit inside the term width
+ --custom-layout! : display the results using a custom layout (see conf)
+ -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
+
+ * Other
+ --invidious! : use the API of invidio.us to get the streaming URLs
+ --proxy=s : set HTTP(S)/SOCKS proxy: 'proto://domain.tld:port/'
+ If authentication required,
+ use 'proto://user:pass\@domain.tld:port/'
+ --dash! : include or exclude the DASH itags
+ --dash-mp4a! : include or exclude the itags for MP4 audio streams
+ --dash-segmented! : include or exclude segmented DASH streams
+
+
+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:
+ * -> requires authentication
+ ! -> 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";
+
+ == fair-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.
+
+-> My channel
+ > By using the string "mine" where a channel ID is required, "mine" will be
+ automatically replaced with your channel ID. (requires authentication)
+
+ Examples:
+ $execname --uploads=mine
+ $execname --likes=mine
+ $execname --favorites=mine
+ $execname --playlists=mine
+
+-> 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.
+ Also, for the previous page, you can insert ':b', but ':r' is faster!
+
+ > "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
+ *LIKES* : the number of likes
+ *DISLIKES* : the number of dislikes
+ *RATING* : the rating of the video from 0 to 5
+ *COMMENTS* : the number of comments
+ *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)
+ *DIMENSION* : the dimension of the video (2D or 3D)
+ *DEFINITION* : the definition of the video (HD or SD)
+ *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)
+
+ *CAPTION* : true if the video has captions
+ *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 -category=10
+Results: play all the video results (-A)
+ only audio, no video (-n)
+ search for "russian music"
+ in category "10", which is the Music category.
+ -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 -D
+Results: the most recent 5 videos by a specific author (-up), printed with extra details (-D).
+
+Command: $execname --author=MIT atom
+Results: search only in videos by a specific author.
+
+Command: $execname --author=MIT atom --within=2y
+Results: search only in videos by a specific author, published in the last 2 years.
+
+Command: $execname --popular=MIT --within=6m
+Results: show the most popular videos by a specific user, published in the last 6 months.
+
+Command: $execname -S=vsauce
+Results: get the subscriptions for a username.
+
+Command: $execname --page=2 -u=Google
+Results: show latest videos uploaded by Google, starting with the page number 2.
+
+Command: $execname cats --order=viewCount --duration=short
+Results: search for 'cats' videos, ordered by ViewCount and short duration.
+
+Command: $execname --channels math lessons
+Results: search for YouTube channels.
+
+Command: $execname -uf=Google
+Results: show latest videos favorited by a user.
+
+
+==== USER INPUT EXAMPLES ====
+
+A STDIN option can begin with ':', ';' or '='.
+
+Command: <ENTER>, :n, :next
+Results: get the next page of results.
+
+Command: :b, :back (:r, :return)
+Results: get 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(
+ api_host
+ videoCaption maxResults order
+ videoDefinition videoCategoryId
+ videoDimension videoDuration
+ videoEmbeddable videoLicense
+ videoSyndicated channelId
+ publishedAfter publishedBefore
+ safeSearch regionCode debug hl
+ http_proxy page comments_order
+ subscriptions_order
+ )
+ ) {
+
+ if (defined $opt->{$option_name}) {
+ my $code = \&{"WWW::FairViewer::set_$option_name"};
+ my $value = delete $opt->{$option_name};
+ 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";
+ }
+ }
+ }
+
+ if (defined $opt->{category_id}) {
+
+ my $str = delete $opt->{category_id};
+ my $category = extract_category($str);
+
+ if (ref($category) eq 'HASH') {
+ say ":: Category selected: $category->{snippet}{title}" if $yv_obj->get_debug;
+ $yv_obj->set_videoCategoryId($category->{id});
+ }
+ else {
+ warn_invalid('category', $str);
+ }
+ }
+
+ if (defined $opt->{prefer_mp4}) {
+ $yv_obj->set_prefer_mp4(delete($opt->{prefer_mp4}) ? 1 : 0);
+ }
+
+ if (defined $opt->{prefer_av1}) {
+ $yv_obj->set_prefer_av1(delete($opt->{prefer_av1}) ? 1 : 0);
+ }
+
+ if (defined $opt->{hd}) {
+ $yv_obj->set_videoDefinition(delete($opt->{hd}) ? 'high' : 'any');
+ }
+
+ 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;
+ };
+ }
+
+ if ($id eq 'mine') {
+ $id = $yv_obj->my_channel_id() // do {
+ warn_invalid("username or channel ID", $id);
+ undef;
+ };
+ }
+
+ $yv_obj->set_channelId($id);
+ }
+ else {
+ warn_invalid("username or channel ID", $name);
+ }
+ }
+
+ if (defined $opt->{within}) {
+ my $value = delete $opt->{within};
+
+ if ($value =~ /^\s*(\d+(?:\.\d+)?)([dwmy])/i) {
+ my $date = $yv_utils->period_to_date($1, $2);
+ $yv_obj->set_publishedAfter($date);
+ }
+ else {
+ warn "\n[!] Invalid value <$value> for option `--within`!\n";
+ }
+ }
+
+ if (defined $opt->{more_results}) {
+ $yv_obj->set_maxResults(delete($opt->{more_results}) ? 50 : $CONFIG{maxResults});
+ }
+
+ if (delete $opt->{authenticate}) {
+ authenticate();
+ }
+
+ if (delete $opt->{logout}) {
+ logout();
+ }
+
+ # ... 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}) {
+ subscribe(split(/[,\s]+/, delete $opt->{subscribe}));
+ }
+
+ if (defined $opt->{favorite_video}) {
+ favorite_videos(split(/[,\s]+/, delete $opt->{favorite_video}));
+ }
+
+ if (defined $opt->{playlist_save}) {
+ my @ids = split(/[,\s]+/, delete $opt->{playlist_save});
+ if (defined $opt->{playlist_id}) {
+ save_to_playlist(get_valid_playlist_id(delete $opt->{playlist_id}) // (return), @ids);
+ }
+ else {
+ select_and_save_to_playlist(@ids);
+ }
+ }
+
+ 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->{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->{categories}) {
+ print_categories($yv_obj->video_categories);
+ }
+
+ if (defined $opt->{uploads}) {
+ my $str = delete $opt->{uploads};
+
+ if ($str) {
+ if (my $id = extract_channel_id($str)) {
+ $yv_utils->is_channelID($id)
+ ? print_videos($yv_obj->uploads($id))
+ : print_videos($yv_obj->uploads_from_username($id));
+ }
+ else {
+ warn_invalid("username or channel ID", $str);
+ }
+ }
+ else {
+ print_videos($yv_obj->uploads);
+ }
+ }
+
+ if (defined $opt->{popular_videos}) {
+ my $str = delete $opt->{popular_videos};
+
+ 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;
+ };
+ }
+
+ if ($id eq 'mine') {
+ $id = $yv_obj->my_channel_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->{trending}) {
+
+ my $str = delete $opt->{trending};
+ my $category = extract_category($str);
+ my $cat_id = undef;
+
+ if (ref($category) eq 'HASH') {
+ say ":: Category selected: $category->{snippet}{title}" if $yv_obj->get_debug;
+ $cat_id = $category->{id};
+ }
+ elsif ($str) {
+ warn_invalid('category', $str);
+ }
+
+ print_videos($yv_obj->trending_videos_from_category($cat_id));
+ }
+
+ if (defined $opt->{subscriptions}) {
+ my $str = delete $opt->{subscriptions};
+
+ if ($str) {
+ if (my $id = extract_channel_id($str)) {
+ $yv_utils->is_channelID($id)
+ ? print_channels($yv_obj->subscriptions($id))
+ : print_channels($yv_obj->subscriptions_from_username($id));
+ }
+ else {
+ warn_invalid("username or channel ID", $str);
+ }
+ }
+ else {
+ print_channels($yv_obj->subscriptions);
+ }
+ }
+
+ if (defined $opt->{subscription_videos}) {
+ my $str = delete $opt->{subscription_videos};
+
+ if ($str) {
+ if (my $id = extract_channel_id($str)) {
+ $yv_utils->is_channelID($id)
+ ? print_videos($yv_obj->subscription_videos($id))
+ : print_videos($yv_obj->subscription_videos_from_username($id));
+ }
+ else {
+ warn_invalid("username or channel ID", $str);
+ }
+ }
+ else {
+ print_videos($yv_obj->subscription_videos);
+ }
+ }
+
+ 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)) {
+ $yv_utils->is_channelID($id)
+ ? print_playlists($yv_obj->playlists($id))
+ : print_playlists($yv_obj->playlists_from_username($id));
+ }
+ else {
+ warn_invalid("username or channel ID", $str);
+ warn colored("[+] To search for playlists, try: $0 -sp $str", 'bold yellow') . "\n";
+ }
+ }
+ else {
+ print_playlists($yv_obj->my_playlists);
+ }
+ }
+
+ if (defined $opt->{favorites}) {
+ my $str = delete($opt->{favorites});
+
+ if ($str) {
+ if (my $id = extract_channel_id($str)) {
+ $yv_utils->is_channelID($id)
+ ? print_videos($yv_obj->favorites($id))
+ : print_videos($yv_obj->favorites_from_username($id));
+ }
+ else {
+ warn_invalid("username or channel ID", $str);
+ }
+ }
+ else {
+ print_videos($yv_obj->favorites);
+ }
+ }
+
+ if (defined $opt->{likes}) {
+ my $str = delete($opt->{likes});
+
+ if ($str) {
+ if (my $id = extract_channel_id($str)) {
+ $yv_utils->is_channelID($id)
+ ? print_videos($yv_obj->likes($id))
+ : print_videos($yv_obj->likes_from_username($id));
+ }
+ else {
+ warn_invalid("username or channel ID", $str);
+ }
+ }
+ else {
+ print_videos($yv_obj->my_likes);
+ }
+ }
+
+ if (defined $opt->{dislikes}) {
+ delete $opt->{dislikes};
+ print_videos($yv_obj->my_dislikes);
+ }
+
+ if (defined $opt->{activities}) {
+ my $str = delete $opt->{activities};
+
+ if ($str) {
+ if (my $id = extract_channel_id($str)) {
+ $yv_utils->is_channelID($id)
+ ? print_videos($yv_obj->activities($id))
+ : print_videos($yv_obj->activities_from_username($id));
+ }
+ else {
+ warn_invalid("username or channel ID", $str);
+ }
+ }
+ else {
+ print_videos($yv_obj->my_activities);
+ }
+ }
+
+ 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
+ '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 },
+ 'res|resolution=s' => \$opt{resolution},
+
+ 'comments=s' => \$opt{get_comments},
+ 'comments-order=s' => \$opt{comments_order},
+
+ 'c|categories' => \$opt{categories},
+ 'video-ids|videoids|id|ids=s' => \$opt{play_video_ids},
+
+ 'search-videos|search|sv!' => \$opt{search_videos},
+ 'search-channels|channels|sc!' => \$opt{search_channels},
+ 'search-playlists|sp|p!' => \$opt{search_playlists},
+
+ 'subscriptions|S:s' => \$opt{subscriptions},
+ 'subs-videos|SV:s' => \$opt{subscription_videos},
+ 'subs-order=s' => \$opt{subscriptions_order},
+ 'uploads|U|user|user-videos|uv|u:s' => \$opt{uploads},
+ 'favorites|F|user-favorites|uf:s' => \$opt{favorites},
+ 'playlists|P|user-playlists|up:s' => \$opt{playlists},
+ 'activities|activity|ua:s' => \$opt{activities},
+ 'likes|L|user-likes|ul:s' => \$opt{likes},
+ 'dislikes' => \$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},
+
+ 'login|authenticate' => \$opt{authenticate},
+ 'logout' => \$opt{logout},
+
+ 'related-videos|rv=s' => \$opt{related_videos},
+ 'popular-videos|popular|pv=s' => \$opt{popular_videos},
+
+ 'http_proxy|http-proxy|proxy=s' => \$opt{http_proxy},
+
+ 'catlang|cl|hl=s' => \$opt{hl},
+ 'category|cat-id|cat=s' => \$opt{category_id},
+ 'r|region|region-code=s' => \$opt{regionCode},
+
+ 'orderby|order|order-by=s' => \$opt{order},
+ 'duration=s' => \$opt{videoDuration},
+ 'within=s' => \$opt{within},
+
+ '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},
+ 'safe-search|safeSearch=s' => \$opt{safeSearch},
+ 'vd|video-definition=s' => \$opt{videoDefinition},
+ 'hd|high-definition!' => \$opt{hd},
+ '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' => \$MPLAYER{user_defined_arguments},
+
+ # Others
+ 'colorful|colourful|C!' => \$opt{results_with_colors},
+ 'details|D!' => \$opt{results_with_details},
+ 'fixed-width|W|fw!' => \$opt{results_fixed_width},
+ 'caption=s' => \$opt{videoCaption},
+ 'fullscreen|fs|f!' => \$opt{fullscreen},
+ 'dash!' => \$opt{dash_support},
+ 'confirm!' => \$opt{confirm},
+
+ 'prefer-mp4!' => \$opt{prefer_mp4},
+ 'prefer-av1!' => \$opt{prefer_av1},
+
+ 'custom-layout!' => \$opt{custom_layout},
+ 'custom-layout-format=s' => \$opt{custom_layout_format},
+
+ 'merge-into-mkv|mkv-merge!' => \$opt{merge_into_mkv},
+ 'merge-with-captions|merge-captions!' => \$opt{merge_with_captions},
+
+ 'invidious!' => \$opt{use_invidious_api},
+ 'convert-command|convert-cmd=s' => \$opt{convert_cmd},
+ 'dash-m4a|dash-mp4-audio|dash-mp4a!' => \$opt{dash_mp4_audio},
+ 'dash-segmented!' => \$opt{dash_segmented},
+ '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|video-info=s' => \$opt{print_video_info},
+ 'get-term-width!' => \$opt{get_term_width},
+ 'page=i' => \$opt{page},
+ 'novideo|no-video|n|audio!' => \$opt{novideo},
+ 'autohide!' => \$opt{autohide_watched},
+ 'highlight!' => \$opt{highlight_watched},
+ 'skip-watched!' => \$opt{skip_watched},
+ 'results=i' => \$opt{maxResults},
+ 'shuffle|s!' => \$opt{shuffle},
+ 'more|m!' => \$opt{more_results},
+ 'pos|position=i' => \$opt{position},
+ 'ps|playlist-save=s' => \$opt{playlist_save},
+
+ '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},
+
+ 'thousand-separator=s' => \$opt{thousand_separator},
+ 'get-captions|get_captions!' => \$opt{get_captions},
+ 'auto-captions|auto_captions!' => \$opt{auto_captions},
+ 'copy-caption|copy_caption!' => \$opt{copy_caption},
+ 'captions-dir|captions_dir=s' => \$opt{captions_dir},
+ '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 chr ord $arg eq q{-};
+
+ if (youtube_urls($arg)) {
+ splice(@ARGV, $i--, 1);
+ }
+}
+
+if (my @keywords = grep chr ord 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 <STDIN>));
+}
+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_category {
+ my ($str) = @_;
+
+ $str || return;
+
+ state $results = $yv_obj->video_categories;
+ return if ref($results) ne 'HASH';
+
+ my $categories = $results->{items};
+ return if ref($categories) ne 'ARRAY';
+
+ foreach my $category (@$categories) {
+ if ($category->{id} eq $str) {
+ return $category;
+ }
+ }
+
+ my $str_re = qr/\Q$str\E/i;
+
+ foreach my $category (@$categories) {
+ if ($yv_utils->get_title($category) =~ /$str_re/) {
+ return $category;
+ }
+ }
+
+ return;
+}
+
+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 mplayer
+sub get_mplayer {
+ if ($constant{win32}) {
+ my $smplayer = catfile($ENV{ProgramFiles}, qw(SMPlayer mplayer mplayer.exe));
+
+ if (not -e $smplayer) {
+ warn "\n\n!!! Please install SMPlayer in order to stream YouTube videos.\n\n";
+ }
+
+ return $smplayer; # Windows MPlayer
+ }
+
+ return 'mplayer'; # *NIX MPlayer
+}
+
+# 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
+$action_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 =~ 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+/, 1, $input)) {
+ if (chr ord $word 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})
+ : ($term->readline($text) // return ':return')
+ ) =~ s/^\s+//r;
+
+ return q{:next} if $input eq q{}; # <ENTER> 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,2}\z/) {
+ $term->append_history(1, $opt{history_file});
+ }
+ }
+
+ apply_input_arguments($args, $keywords);
+ return @{$keywords};
+}
+
+sub logout {
+
+ unlink $authentication_file
+ or warn "Can't unlink: `$authentication_file' -> $!";
+
+ $yv_obj->set_access_token();
+ $yv_obj->set_refresh_token();
+
+ return 1;
+}
+
+sub authenticate {
+ my $get_code_url = $yv_obj->get_accounts_oauth_url() // return;
+
+ print <<"INFO";
+
+:: Get the authentication code: $get_code_url
+
+ |
+... and paste it below. \\|/
+ `
+INFO
+
+ my $code = $term->readline(colored(q{Code: }, 'bold')) || return;
+
+ my $info = $yv_obj->oauth_login($code) // do {
+ warn "[WARNING] Can't log in... That's all I know...\n";
+ return;
+ };
+
+ if (defined $info->{access_token}) {
+
+ $yv_obj->set_access_token($info->{access_token}) // return;
+ $yv_obj->set_refresh_token($info->{refresh_token}) // return;
+
+ my $remember_me = ask_yn(prompt => colored("\nRemember me", 'bold'),
+ default => 'y');
+
+ if ($remember_me) {
+ $yv_obj->set_authentication_file($authentication_file);
+ $yv_obj->save_authentication_tokens()
+ or warn "Can't store the authentication tokens: $!";
+ }
+ else {
+ $yv_obj->set_authentication_file();
+ }
+
+ return 1;
+ }
+
+ warn "[WARNING] There was a problem with the authentication...\n";
+ return;
+}
+
+sub authenticated {
+ if (not defined $yv_obj->get_access_token) {
+ warn_needs_auth();
+ return;
+ }
+ return 1;
+}
+
+sub favorite_videos {
+ my (@videoIDs) = @_;
+ return if not authenticated();
+
+ foreach my $id (@videoIDs) {
+ my $videoID = get_valid_video_id($id) // next;
+
+ if ($yv_obj->favorite_video($videoID)) {
+ printf("\n:: Video %s has been successfully favorited.\n", sprintf($CONFIG{youtube_video_url}, $videoID));
+ }
+ else {
+ warn_cant_do('favorite', $videoID);
+ }
+ }
+ return 1;
+}
+
+sub select_and_save_to_playlist {
+ return if not authenticated();
+
+ my $request = $yv_obj->my_playlists() // return;
+ my $playlistID = print_playlists($request, return_playlist_id => 1);
+
+ if (defined($playlistID)) {
+ return save_to_playlist($playlistID, @_);
+ }
+
+ warn_no_thing_selected('playlist');
+ return;
+
+}
+
+sub save_to_playlist {
+ my ($playlistID, @videoIDs) = @_;
+
+ return if not authenticated();
+
+ foreach my $id (@videoIDs) {
+ my $videoID = get_valid_video_id($id) // next;
+ my $pos = $opt{position}; # position in the playlist
+
+ if (!defined($pos) or $pos < 0) {
+ local $yv_obj->{maxResults} = 1;
+
+ my $info = $yv_obj->videos_from_playlist_id($playlistID);
+ my $total_results = $info->{results}{pageInfo}{totalResults};
+
+ say "\n:: Total number of videos in the playlist: $total_results";
+ $pos //= 0;
+ $pos += $total_results || 0;
+ say ":: Saving video at position: $pos";
+ }
+
+ if ($yv_obj->add_video_to_playlist($playlistID, $videoID, $pos)) {
+ printf(":: Video %s has been successfully added to playlistID: %s\n",
+ sprintf($CONFIG{youtube_video_url}, $videoID), $playlistID);
+ }
+ else {
+ warn_cant_do("add to playlist", $videoID);
+ }
+ }
+ return 1;
+}
+
+sub rate_videos {
+ my $rating = shift;
+ return if not authenticated();
+
+ foreach my $id (@_) {
+ my $videoID = get_valid_video_id($id) // next;
+ if ($yv_obj->send_rating_to_video($videoID, $rating)) {
+ printf("\n:: Video %s has been successfully %sd.\n", sprintf($CONFIG{youtube_video_url}, $videoID), $rating);
+ }
+ else {
+ warn_cant_do($rating, $videoID);
+ }
+ }
+
+ return 1;
+}
+
+sub get_and_play_video_ids {
+ (my @ids = grep { get_valid_video_id($_) } @_) || return;
+ my $info = $yv_obj->video_details(join(',', @ids));
+
+ if ($yv_utils->has_entries($info)) {
+ if (not play_videos([$info->{results}])) {
+ return;
+ }
+ }
+ else {
+ warn_cant_do('get info for', @ids);
+ }
+
+ 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 ($yv_utils->has_entries($info)) {
+ local $opt{show_video_info} = 1;
+ print_video_info($info->{results}{items}[0]);
+ }
+ 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 subscribe {
+ my (@ids) = @_;
+
+ return if not authenticated();
+
+ foreach my $channel (@ids) {
+
+ my $id = extract_channel_id($channel) // do {
+ warn_invalid("channel ID or username", $channel);
+ next;
+ };
+
+ my $ok =
+ $yv_utils->is_channelID($id)
+ ? $yv_obj->subscribe_channel($id)
+ : $yv_obj->subscribe_channel_from_username($id);
+
+ if ($ok) {
+ print ":: Successfully subscribed to channel: $id\n";
+ }
+ else {
+ warn colored("\n[!] Unable to subscribe to channel: $id", 'bold red') . "\n";
+ }
+ }
+
+ return 1;
+}
+
+sub _bold_color {
+ my ($text) = @_;
+ return colored($text, 'bold');
+}
+
+sub youtube_urls {
+ my ($arg) = @_;
+
+ if ($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_from_username($+{username}));
+ }
+ elsif ($arg =~ /$get_username_videos_re/) {
+ print_videos($yv_obj->uploads_from_username($+{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 = $args{token};
+
+ if (not defined($option)) {
+ return;
+ }
+
+ if ($option =~ /^(?:q|quit|exit)\z/) {
+ main_quit(0);
+ }
+ elsif ($option =~ /^(?:n|next)\z/ and defined $url) {
+ if (exists $args{token}) {
+ if (defined $token) {
+ my $request = $yv_obj->next_page_with_token($url, $token);
+ $callback->($request);
+ }
+ else {
+ warn_last_page();
+ }
+ }
+ else {
+ my $request = $yv_obj->next_page($url);
+ $callback->($request);
+ }
+ }
+ elsif ($option =~ /^(?:b|back|p|prev|previous)\z/ and defined $url) {
+ my $request = $yv_obj->previous_page($url);
+ $callback->($request);
+ }
+ elsif ($option =~ /^(?:R|refresh)\z/ and defined $url) {
+ @{$results} = @{$yv_obj->_get_results($url)->{results}{items}};
+ }
+ elsif ($option eq 'login') {
+ authenticate();
+ }
+ elsif ($option eq 'logout') {
+ logout();
+ }
+ elsif ($option =~ /^(?:reset|reload|restart)\z/) {
+ @ARGV = ();
+ do $0;
+ }
+ 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($CONFIG{youtube_video_url}, $videoID), 'bold red') . "\n";
+
+ my %info = $yv_obj->_get_video_info($videoID);
+ my $resp = $yv_obj->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[!] No previous page available...", 'bold red') . "\n";
+}
+
+sub warn_no_thing_selected {
+ warn colored("\n[!] No $_[0] selected!", 'bold red') . "\n";
+}
+
+sub warn_needs_auth {
+ warn colored("\n[!] This functionality needs authentication!", '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 <ENTER> 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,2}\z/ && $num != 0 && $num <= @{$array_ref};
+}
+
+sub adjust_width {
+ my ($str, $len, $prepend) = @_;
+
+ $len > 0 or do {
+ warn "[WARN] Insufficient space for the title: increase your terminal width!\n";
+ return $str;
+ };
+
+ state $pkg = (
+ eval {
+ require Unicode::GCString;
+ '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";
+ '';
+ }
+ );
+
+ #
+ ## Unicode::GCString
+ #
+ if ($pkg eq 'Unicode::GCString') {
+
+ my $gcstr = Unicode::GCString->new($str);
+ my $str_width = $gcstr->columns;
+
+ if ($str_width != $len) {
+ while ($str_width > $len) {
+ $gcstr = $gcstr->substr(0, -1);
+ $str_width = $gcstr->columns;
+ }
+
+ $str = $gcstr->as_string;
+ my $spaces = ' ' x ($len - $str_width);
+ $str = $prepend ? "$spaces$str" : "$str$spaces";
+ }
+
+ return $str;
+ }
+
+ #
+ ## Text::CharWidth
+ #
+ if ($pkg eq 'Text::CharWidth') {
+
+ my $str_width = Text::CharWidth::mbswidth($str);
+
+ if ($str_width != $len) {
+ while ($str_width > $len) {
+ chop $str;
+ $str_width = Text::CharWidth::mbswidth($str);
+ }
+
+ my $spaces = ' ' x ($len - $str_width);
+ $str = $prepend ? "$spaces$str" : "$str$spaces";
+ }
+
+ return $str;
+ }
+
+ return $str;
+}
+
+# ... PRINT SUBROUTINES ... #
+sub print_channels {
+ my ($results) = @_;
+
+ if (not $yv_utils->has_entries($results)) {
+ warn_no_results("channel");
+ }
+
+ if ($opt{get_term_width} and $opt{results_fixed_width}) {
+ get_term_width();
+ }
+
+ my $url = $results->{url};
+ my $channels = $results->{results} // [];
+
+ foreach my $i (0 .. $#{$channels}) {
+ my $channel = $channels->[$i];
+
+ if ($opt{results_with_details}) {
+ printf(
+ "\n%s. %s\n %s: %-23s %s: %-12s\n%s\n",
+ colored(sprintf('%2d', $i + 1), 'bold') => colored($yv_utils->get_channel_title($channel), 'bold blue'),
+ colored('Updated' => 'bold') => $yv_utils->get_publication_date($channel),
+ colored('Author' => 'bold') => $yv_utils->get_channel_title($channel),
+ wrap_text(
+ i_tab => q{ } x 4,
+ s_tab => q{ } x 4,
+ text => [$yv_utils->get_description($channel) || 'No description available...']
+ ),
+ );
+ }
+ elsif ($opt{results_with_colors}) {
+ print "\n" if $i == 0;
+ printf("%s. %s (%s)\n",
+ colored(sprintf('%2d', $i + 1), 'bold'),
+ colored($yv_utils->get_channel_title($channel), 'blue'),
+ colored($yv_utils->get_publication_date($channel), 'magenta'),
+ );
+ }
+ elsif ($opt{results_fixed_width}) {
+
+ require List::Util;
+
+ my @authors = map { $yv_utils->get_channel_id($_) } @{$channels};
+ my @dates = map { $yv_utils->get_publication_date($_) } @{$channels};
+
+ my $author_width = List::Util::min(List::Util::max(map { length($_) } @authors), int($term_width / 5));
+ my $dates_width = List::Util::max(map { length($_) } @dates);
+ my $title_length = $term_width - ($author_width + $dates_width + 2 + 3 + 1 + 2);
+
+ print "\n";
+ foreach my $i (0 .. $#{$channels}) {
+
+ my $channel = $channels->[$i];
+ my $title = clear_title($yv_utils->get_channel_title($channel));
+
+ printf "%s. %s %s [%*s]\n", colored(sprintf('%2d', $i + 1), 'bold'),
+ adjust_width($title, $title_length),
+ adjust_width($authors[$i], $author_width, 1),
+ $dates_width, $dates[$i];
+ }
+ last;
+ }
+ else {
+ print "\n" if $i == 0;
+ printf "%s. %s [%s]\n", colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_title($channel),
+ $yv_utils->get_publication_date($channel);
+ }
+ }
+
+ 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
+ }
+ 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, $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} // [];
+ my $token = $results->{results}{continuation};
+
+ my $i = 0;
+ foreach my $comment (@{$comments}) {
+ my $comment_id = $yv_utils->get_comment_id($comment);
+ my $comment_age = $yv_utils->get_publication_age_approx($comment);
+
+ printf(
+ "\n%s (%s) commented:\n%s\n",
+ colored($yv_utils->get_author($comment), 'bold'),
+ (
+ $comment_age =~ /sec|min|hour|day/
+ ? "$comment_age ago"
+ : $yv_utils->get_publication_date($comment)
+ ),
+ wrap_text(
+ i_tab => q{ } x 3,
+ s_tab => q{ } x 3,
+ text => [$yv_utils->get_comment_content($comment) // 'Empty comment...']
+ ),
+ );
+
+ #~ if (exists $comment->{replies}) {
+ #~ foreach my $reply (reverse @{$comment->{replies}{comments}}) {
+ #~ my $reply_age = $yv_utils->date_to_age($reply->{snippet}{publishedAt});
+ #~ printf(
+ #~ "\n %s (%s) replied:\n%s\n",
+ #~ colored($reply->{snippet}{authorDisplayName}, 'bold'),
+ #~ (
+ #~ $reply_age =~ /sec|min|hour|day/
+ #~ ? "$reply_age ago"
+ #~ : $yv_utils->format_date($reply->{snippet}{publishedAt})
+ #~ ),
+ #~ wrap_text(
+ #~ i_tab => q{ } x 6,
+ #~ s_tab => q{ } x 6,
+ #~ text => [$reply->{snippet}{textDisplay} // 'Empty comment...']
+ #~ ),
+ #~ );
+ #~ }
+ #~ }
+ }
+
+ 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],
+ token => $token,
+ )
+ ) {
+ ## ok
+ }
+ elsif ($opt =~ /^(?:h|help)\z/) {
+ print $comments_help;
+ press_enter_to_continue();
+ }
+ elsif ($opt =~ /^(?:c|comment)\z/) {
+ if (authenticated()) {
+ require File::Temp;
+ my ($fh, $filename) = File::Temp::tempfile();
+ $yv_obj->proxy_system($ENV{EDITOR} // 'nano', $filename);
+ if ($?) {
+ warn colored("\n[!] Editor exited with a non-zero code. Unable to continue!", 'bold red') . "\n";
+ }
+ else {
+ my $comment = do { local (@ARGV, $/) = $filename; <> };
+ $comment =~ s/[^\s[:^cntrl:]]+//g; # remove control characters
+
+ if (length($comment) and $yv_obj->comment_to_video_id($comment, $videoID)) {
+ print "\n:: Comment posted!\n";
+ }
+ else {
+ warn colored("\n[!] Your comment has NOT been posted!", 'bold red') . "\n";
+ }
+ }
+ }
+ }
+ 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->get_videos_from_username($comments->[$key - 1]{author}));
+ }
+ else {
+ warn_invalid('keyword', $key);
+ }
+ }
+
+ __SUB__->(@_);
+}
+
+sub print_categories {
+ my ($results) = @_;
+
+ return if ref($results) ne 'HASH';
+ my $categories = $results->{items};
+ return if ref($categories) ne 'ARRAY';
+
+ my $i = 0;
+ print "\n" if @{$categories};
+
+ # Filter out nonassignable categories
+ @$categories = grep { $_->{snippet}{assignable} } @$categories;
+
+ foreach my $category (@{$categories}) {
+ printf "%s. %-40s (id: %s)\n", colored(sprintf('%2d', ++$i), 'bold'), $yv_utils->get_title($category), $category->{id};
+ }
+
+ 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->videos_from_category($cat_id);
+
+ if (not $yv_utils->has_entries($videos)) {
+ $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} and $opt{results_fixed_width}) {
+ get_term_width();
+ }
+
+ my $url = $results->{url};
+ my $playlists = $results->{results} // [];
+
+ state $info_format = <<"FORMAT";
+
+TITLE: %s
+ ID: %s
+ URL: https://www.youtube.com/playlist?list=%s
+DESCR: %s
+FORMAT
+
+ foreach my $i (0 .. $#{$playlists}) {
+ my $playlist = $playlists->[$i];
+ if ($opt{results_with_details}) {
+ printf(
+ "\n%s. %s\n %s: %-25s %s: %s\n%s\n",
+ colored(sprintf('%2d', $i + 1), 'bold') => colored($yv_utils->get_title($playlist), 'bold blue'),
+ colored('Updated' => 'bold') => $yv_utils->get_publication_date($playlist),
+ colored('Author' => 'bold') => $yv_utils->get_channel_title($playlist),
+ wrap_text(
+ i_tab => q{ } x 4,
+ s_tab => q{ } x 4,
+ text => [$yv_utils->get_description($playlist) || 'No description available...']
+ ),
+ );
+ }
+ elsif ($opt{results_with_colors}) {
+ print "\n" if $i == 0;
+ printf(
+ "%s. %s (%s) %s\n",
+ colored(sprintf('%2d', $i + 1), 'bold'),
+ colored($yv_utils->get_title($playlist), 'blue'),
+ colored($yv_utils->get_publication_date($playlist), 'magenta'),
+ colored($yv_utils->get_channel_title($playlist), 'green'),
+ );
+ }
+ elsif ($opt{results_fixed_width}) {
+
+ require List::Util;
+
+ my @authors = map { $yv_utils->get_channel_title($_) } @{$playlists};
+ my @dates = map { $yv_utils->get_publication_date($_) } @{$playlists};
+
+ my $author_width = List::Util::min(List::Util::max(map { length($_) } @authors), int($term_width / 5));
+ my $dates_width = List::Util::max(map { length($_) } @dates);
+ my $title_length = $term_width - ($author_width + $dates_width + 2 + 3 + 1 + 2);
+
+ print "\n";
+ foreach my $i (0 .. $#{$playlists}) {
+
+ my $playlist = $playlists->[$i];
+ my $title = clear_title($yv_utils->get_title($playlist));
+
+ printf "%s. %s %s [%*s]\n", colored(sprintf('%2d', $i + 1), 'bold'),
+ adjust_width($title, $title_length),
+ adjust_width($authors[$i], $author_width, 1),
+ $dates_width, $dates[$i];
+ }
+ last;
+ }
+ else {
+ print "\n" if $i == 0;
+ printf(
+ "%s. %s (by %s) [%s]\n",
+ colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_title($playlist),
+ $yv_utils->get_channel_title($playlist), $yv_utils->get_publication_date($playlist)
+ );
+ }
+ }
+
+ 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;
+ }
+ elsif ($opt =~ /^i(?:nfo)?${digit_or_equal_re}(.*)/) {
+ if (my @ids = get_valid_numbers($#{$playlists}, $1)) {
+ foreach my $id (@ids) {
+ my $desc = wrap_text(
+ i_tab => q{ } x 7,
+ s_tab => q{ } x 7,
+ text => [$yv_utils->get_description($playlists->[$id]) || 'No description available...']
+ );
+ $desc =~ s/^\s+//;
+ printf $info_format, $yv_utils->get_title($playlists->[$id]),
+ ($yv_utils->get_playlist_id($playlists->[$id])) x 2, $desc;
+ }
+ press_enter_to_continue();
+ }
+ else {
+ warn_no_thing_selected('playlist');
+ }
+ }
+ 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) {
+ if ($args{return_playlist_id}) {
+ return $yv_utils->get_playlist_id($playlists->[$key - 1]);
+ }
+ get_and_print_videos_from_playlist($yv_utils->get_playlist_id($playlists->[$key - 1]));
+ }
+ else {
+ push @for_search, $key;
+ }
+ }
+
+ if (@for_search) {
+ __SUB__->($yv_obj->search_playlists(\@for_search));
+ }
+
+ __SUB__->(@_);
+}
+
+sub compile_regex {
+ my ($value) = @_;
+ $value =~ s{^(?<quote>['"])(?<regex>.+)\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,2}\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::FairViewer::GetCaption;
+ my $yv_cap = WWW::FairViewer::GetCaption->new(
+ auto_captions => $opt{auto_captions},
+ captions_dir => $opt{captions_dir},
+ captions => $captions,
+ languages => $CONFIG{srt_languages},
+ );
+ $srt_file = $yv_cap->save_caption($video_id);
+ }
+
+ require WWW::FairViewer::Itags;
+ state $yv_itags = WWW::FairViewer::Itags->new();
+
+ # Include DASH itags
+ my $dash = 1;
+
+ # Exclude DASH itags in download-mode or when no video output is required
+ if ($opt{novideo} or not $opt{dash_support}) {
+ $dash = 0;
+ }
+ elsif ($opt{download_video}) {
+ $dash = $opt{merge_into_mkv} ? 1 : 0;
+ }
+
+ my ($streaming, $resolution) =
+ $yv_itags->find_streaming_url(
+ urls => $urls,
+ resolution => ($opt{novideo} ? 'audio' : $opt{resolution}),
+ dash => $dash,
+ dash_mp4_audio => ($opt{novideo} ? 1 : $opt{dash_mp4_audio}),
+ dash_segmented => ($opt{download_video} ? 0 : $opt{dash_segmented}),
+ );
+
+ return {
+ streaming => $streaming,
+ srt_file => $srt_file,
+ info => $info,
+ resolution => $resolution,
+ };
+}
+
+sub download_from_url {
+ my ($url, $output_filename) = @_;
+
+ # Download with wget
+ if ($opt{download_with_wget}) {
+ my @cmd = ($opt{wget_cmd}, '-c', '-t', '10', '--waitretry=3', $url, '-O', "$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);
+ 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 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36',
+ );
+
+ $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($url, $output_filename);
+ }
+
+ rename("$output_filename.part", $output_filename) or return undef;
+ return $output_filename;
+}
+
+sub download_video {
+ my ($streaming, $info) = @_;
+
+ my $fat32safe = $opt{fat32safe};
+ state $unix_like = $^O =~ /^(?:linux|freebsd|openbsd)\z/i;
+
+ if (not $fat32safe and not $unix_like) {
+ $fat32safe = 1;
+ }
+
+ my $video_filename = $yv_utils->format_text(
+ streaming => $streaming,
+ info => $info,
+ text => $opt{video_filename_format},
+ escape => 0,
+ fat32safe => $fat32safe,
+ );
+
+ my $naked_filename = $video_filename =~ s/\.\w+\z//r;
+
+ $naked_filename =~ s/:\h*/ - /g; # replace colons (":") with dashes ("-")
+
+ 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__};
+
+ if ($audio_info) {
+ $audio_filename .= "." . $yv_utils->extension($audio_info->{type});
+ }
+
+ if (not -d $opt{downloads_dir}) {
+ require File::Path;
+ if (not File::Path::make_path($opt{downloads_dir})) {
+ warn colored("\n[!] Can't create directory '$opt{downloads_dir}': $1", 'bold red') . "\n";
+ }
+ }
+
+ if (not -w $opt{downloads_dir}) {
+ warn colored("\n[!] Can't write into directory '$opt{downloads_dir}': $!", 'bold red') . "\n";
+ $opt{downloads_dir} = (-w curdir()) ? curdir() : (-w $ENV{HOME}) ? $ENV{HOME} : return;
+ warn colored("[!] Video will be downloaded into directory: $opt{downloads_dir}", 'bold red') . "\n";
+ }
+
+ $mkv_filename = catfile($opt{downloads_dir}, $mkv_filename);
+ $srt_filename = catfile($opt{downloads_dir}, $srt_filename);
+ $audio_filename = catfile($opt{downloads_dir}, $audio_filename);
+ $video_filename = catfile($opt{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->{url}, $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->{url}, $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;
+ }
+ }
+
+ # Convert the downloaded video
+ if (defined $opt{convert_to}) {
+ my $convert_filename = "$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 from captions-dir 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);
+ }
+
+ return 1;
+}
+
+sub save_watched_video {
+ my ($video_id) = @_;
+
+ if ($opt{remember_watched} and not exists($watched_videos{$video_id})) {
+ $watched_videos{$video_id} = 1;
+ open my $fh, '>>', $opt{watched_file} or return;
+ say {$fh} $video_id;
+ close $fh;
+ }
+
+ $watched_videos{$video_id} = 1;
+ return 1;
+}
+
+sub get_player_command {
+ my ($streaming, $video) = @_;
+
+ $MPLAYER{fullscreen} = $opt{fullscreen} ? $opt{video_players}{$opt{video_player_selected}}{fs} // '' : q{};
+ $MPLAYER{novideo} = $opt{novideo} ? $opt{video_players}{$opt{video_player_selected}}{novideo} // '' : q{};
+ $MPLAYER{mplayer_arguments} = $opt{video_players}{$opt{video_player_selected}}{arg} // q{};
+
+ my $cmd = join(
+ q{ },
+ (
+ # Video player
+ $opt{video_players}{$opt{video_player_selected}}{cmd},
+
+ ( # Audio file (https://)
+ ref($streaming->{streaming}{__AUDIO__}) eq 'HASH'
+ && exists($opt{video_players}{$opt{video_player_selected}}{audio})
+ ? $opt{video_players}{$opt{video_player_selected}}{audio}
+ : ()
+ ),
+
+ ( # Subtitle file (.srt)
+ defined($streaming->{srt_file})
+ && exists($opt{video_players}{$opt{video_player_selected}}{srt})
+ ? $opt{video_players}{$opt{video_player_selected}}{srt}
+ : ()
+ ),
+
+ # Rest of the arguments
+ grep({ defined($_) and /\S/ } values %MPLAYER)
+ )
+ );
+
+ my $has_video = $cmd =~ /\*(?:VIDEO|URL|ID)\*/;
+
+ $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{ --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 = ($video_id => 1); # make sure we don't get stuck in a loop
+ local $yv_obj->{maxResults} = 10;
+
+ while (1) {
+ get_and_play_video_ids($video_id) || return;
+ my $related = $yv_obj->related_to_videoID($video_id);
+ (my @video_ids = grep { !$seen{$_}++ } map { $yv_utils->get_video_id($_) } @{$related->{results}{items}}) || 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($CONFIG{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') {
+ 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;
+ }
+ }
+ 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";
+ }
+
+ $yv_obj->proxy_system($command); # execute the video player
+
+ if ($? and $? != 512) {
+ $opt{auto_next_page} = 0;
+ return;
+ }
+ }
+
+ save_watched_video($video_id);
+ 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::FairViewer::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_video_info {
+ my ($video) = @_;
+
+ $opt{show_video_info} || return 1;
+
+ 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($CONFIG{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;
+
+ 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]),
+ (
+ ['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)],
+ ['Definition' => $yv_utils->get_definition($video)],
+ ['Duration' => $yv_utils->get_time($video)],
+ ['Likes' => $yv_utils->set_thousands($yv_utils->get_likes($video))],
+ ['Dislikes' => $yv_utils->set_thousands($yv_utils->get_dislikes($video))],
+ ['Comments' => $yv_utils->set_thousands($yv_utils->get_comments($video))],
+ ['Views' => $yv_utils->set_thousands($yv_utils->get_views($video))],
+ ['Published' => $yv_utils->get_publication_date($video)],
+ )),
+ "$hr\n";
+
+ return 1;
+}
+
+sub print_videos {
+ my ($results, %args) = @_;
+
+ # use Data::Dump qw(pp);
+ # pp $results;
+
+ if (not $yv_utils->has_entries($results)) {
+ warn_no_results("video");
+ }
+
+ if ($opt{get_term_width} and $opt{results_fixed_width}) {
+ get_term_width();
+ }
+
+ my $url = $results->{url};
+ my $videos = $results->{results} // [];
+
+ if (ref($videos) eq 'HASH' and exists $videos->{videos}) {
+ $videos = $videos->{videos};
+ }
+
+ #my $videos = $info->{items} // [];
+
+ #~ foreach my $entry (@$videos) {
+ #~ if ($yv_utils->is_activity($entry)) {
+ #~ my $type = $entry->{snippet}{type};
+
+ #~ if ($type eq 'upload') {
+ #~ $entry->{kind} = 'youtube#video';
+ #~ $entry->{id} = $entry->{contentDetails}{upload}{videoId};
+ #~ }
+
+ #~ if ($type eq 'playlistItem') {
+ #~ $entry->{kind} = 'youtube#video';
+ #~ $entry->{id} = $entry->{contentDetails}{playlistItem}{resourceId}{videoId};
+ #~ }
+
+ #~ if ($type eq 'bulletin' and $entry->{contentDetails}{bulletin}{resourceId}{kind} eq 'youtube#video') {
+ #~ $entry->{kind} = 'youtube#video';
+ #~ $entry->{id} = $entry->{contentDetails}{bulletin}{resourceId}{videoId};
+ #~ }
+ #~ }
+ #~ }
+
+#<<<
+ #~ @$videos = grep {
+ #~ ref($_) eq 'HASH' && ref($_->{id}) eq 'HASH'
+ #~ ? (exists($_->{id}{kind})
+ #~ ? $_->{id}{kind} eq 'youtube#video'
+ #~ : 0)
+ #~ : 1
+ #~ } @$videos;
+#>>>
+
+ if ($opt{shuffle}) {
+ require List::Util;
+ $videos = [List::Util::shuffle(@{$videos})];
+ }
+
+ #~ if (@{$videos} and not $results->{has_extra_info}) {
+
+ #~ my @video_ids = grep { defined } map { $yv_utils->get_video_id($_) } @{$videos};
+ #~ my $content_details = $yv_obj->video_details(join(',', @video_ids), VIDEO_PART);
+ #~ my $video_details = $content_details->{results}{items};
+
+ #~ foreach my $i (0 .. $#{$videos}) {
+ #~ @{$videos->[$i]}{qw(id contentDetails statistics snippet)} =
+ #~ @{$video_details->[$i]}{qw(id contentDetails statistics snippet)};
+ #~ }
+
+ #~ $results->{has_extra_info} = 1;
+ #~ }
+
+#<<<
+ # Filter out private or deleted videos
+ #~ @$videos = grep {
+ #~ $yv_utils->get_video_id($_)
+ #~ and $yv_utils->get_time($_) ne '00:00'
+ #~ } @$videos;
+#>>>
+
+ my @formatted;
+
+ foreach my $i (0 .. $#{$videos}) {
+ my $video = $videos->[$i];
+
+ #use Data::Dump qw(pp);
+ #pp $video;
+
+ if ($opt{custom_layout}) {
+
+ my $entry = $opt{custom_layout_format};
+
+ if (ref($entry) eq '') {
+ $entry =~ s/\*NO\*/sprintf('%2d', $i+1)/ge;
+ $entry = $yv_utils->format_text(
+ info => $video,
+ text => $entry,
+ escape => 0,
+ );
+ push @formatted, "$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 => $video,
+ 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;
+ }
+
+ push @formatted, join(' ', @columns) . "\n";
+ }
+ }
+ elsif ($opt{results_with_details}) {
+ push @formatted,
+ ($i == 0 ? '' : "\n")
+ . sprintf(
+ "%s. %s\n" . " %s: %-16s %s: %-13s %s: %s\n" . " %s: %-12s %s: %-10s %s: %s\n%s\n",
+ colored(sprintf('%2d', $i + 1), 'bold') => colored($yv_utils->get_title($video), 'bold blue'),
+ colored('Views' => 'bold') => $yv_utils->set_thousands($yv_utils->get_views($video)),
+ colored('Likes' => 'bold') => $yv_utils->set_thousands($yv_utils->get_likes($video)),
+ colored('Dislikes' => 'bold') => $yv_utils->set_thousands($yv_utils->get_dislikes($video)),
+ colored('Published' => 'bold') => $yv_utils->get_publication_date($video),
+ colored('Duration' => 'bold') => $yv_utils->get_time($video),
+ colored('Author' => 'bold') => $yv_utils->get_channel_title($video),
+ wrap_text(
+ i_tab => q{ } x 4,
+ s_tab => q{ } x 4,
+ text => [$yv_utils->get_description($video) || 'No description available...']
+ ),
+ );
+ }
+ elsif ($opt{results_with_colors}) {
+ my $definition = $yv_utils->get_definition($video);
+ push @formatted,
+ sprintf(
+ "%s. %s (%s) %s\n",
+ colored(sprintf('%2d', $i + 1), 'bold'),
+ colored($yv_utils->get_title($video), 'blue'),
+ colored($yv_utils->get_time($video), 'magenta'),
+ colored($yv_utils->get_channel_title($video), 'green'),
+ );
+ }
+ elsif ($opt{results_fixed_width}) {
+
+ require List::Util;
+
+ my @durations = map { $yv_utils->get_duration($_) } @{$videos};
+ my @authors = map { $yv_utils->get_channel_title($_) } @{$videos};
+
+ my $author_width = List::Util::min(List::Util::max(map { length($_) } @authors) || 1, int($term_width / 5));
+ my $time_width = List::Util::first(sub { $_ >= 3600 }, @durations) ? 8 : 6;
+ my $title_length = $term_width - ($author_width + $time_width + 3 + 2 + 1);
+
+ foreach my $i (0 .. $#{$videos}) {
+
+ my $video = $videos->[$i];
+ my $title = clear_title($yv_utils->get_title($video));
+
+ push @formatted,
+ sprintf("%s. %s %s %*s\n",
+ colored(sprintf('%2d', $i + 1), 'bold'),
+ adjust_width($title, $title_length),
+ adjust_width($yv_utils->get_channel_title($video), $author_width, 1),
+ $time_width, $yv_utils->get_time($video));
+ }
+ last;
+ }
+ else {
+ push @formatted,
+ sprintf(
+ "%s. %s (by %s) [%s]\n",
+ colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_title($video),
+ $yv_utils->get_channel_title($video), $yv_utils->get_time($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 $info->{prevPageToken}) {
+ __SUB__->($yv_obj->previous_page($url), auto => 1);
+ #}
+ #else {
+ # $opt{play_backwards} = 0;
+ # warn_first_page();
+ # return;
+ #}
+ }
+ else {
+ #if (defined $info->{nextPageToken}) {
+ __SUB__->($yv_obj->next_page($url), 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 $info->{nextPageToken}) {
+ my $request = $yv_obj->next_page($url);
+ __SUB__->($request, @keywords ? (auto => 1) : ());
+ #}
+ #else {
+ # warn_last_page();
+ # if ($opt{auto_next_page}) {
+ # $opt{auto_next_page} = 0;
+ # @copy_of_keywords = ();
+ # last;
+ # }
+ #}
+ }
+ elsif ($opt =~ /^(?:b|back|p|prev|previous)\z/) {
+ #if (defined $info->{prevPageToken}) {
+ __SUB__->($yv_obj->previous_page($url), @keywords ? (auto => 1) : ());
+ #}
+ #else {
+ # warn_first_page();
+ #}
+ }
+ elsif ($opt =~ /^(?:R|refresh)\z/) {
+ @{$videos} = @{$yv_obj->_get_results($url)->{results}{items}};
+ $results->{has_extra_info} = 0;
+ }
+ elsif ($opt =~ /^(?:r|return)\z/) {
+ return;
+ }
+ 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');
+ }
+ }
+ 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');
+ }
+ }
+ elsif ($opt =~ /^(?:A|[Aa]ctivity)${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->activities($channel_id);
+ if ($yv_utils->has_entries($request)) {
+ __SUB__->($request);
+ }
+ else {
+ warn_no_results('activity');
+ }
+ }
+ }
+ else {
+ warn_no_thing_selected('activity');
+ }
+ }
+ elsif ($opt =~ /^(?:ps|s2p)${digit_or_equal_re}(.*)/) {
+ if (my @nums = get_valid_numbers($#{$videos}, $1)) {
+ select_and_save_to_playlist(map { $yv_utils->get_video_id($videos->[$_]) } @nums);
+ }
+ else {
+ warn_no_thing_selected('video');
+ }
+ }
+ elsif ($opt =~ /^(?:p|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');
+ }
+ }
+ elsif ($opt =~ /^((?:dis)?like)${digit_or_equal_re}(.*)/) {
+ my $rating = $1;
+ if (my @nums = get_valid_numbers($#{$videos}, $2)) {
+ rate_videos($rating, map { $yv_utils->get_video_id($videos->[$_]) } @nums);
+ }
+ else {
+ warn_no_thing_selected('video');
+ }
+ }
+ elsif ($opt =~ /^(?:fav|favorite|F)${digit_or_equal_re}(.*)/) {
+ if (my @nums = get_valid_numbers($#{$videos}, $1)) {
+ favorite_videos(map { $yv_utils->get_video_id($videos->[$_]) } @nums);
+ }
+ else {
+ warn_no_thing_selected('video');
+ }
+ }
+ elsif ($opt =~ /^(?:subscribe|S)${digit_or_equal_re}(.*)/) {
+ if (my @nums = get_valid_numbers($#{$videos}, $1)) {
+ subscribe(map { $yv_utils->get_channel_id($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 =~ /^(?:ap|autoplay)${digit_or_equal_re}(.*)/) {
+ if (my ($id) = get_valid_numbers($#{$videos}, $1)) {
+ autoplay($yv_utils->get_video_id($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);
+ }
+
+ if ($opt{autohide_watched}) {
+ splice(@{$videos}, $key, 1) for @for_play;
+ }
+ }
+ 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 <ENTER> in $timeout seconds to stop the :anp option.", 'bold green');
+ eval {
+ local $SIG{ALRM} = sub {
+ die "alarm\n";
+ };
+ alarm $timeout;
+ scalar <STDIN>;
+ 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 {
+ scalar $term->readline(colored("\n=>> Press ENTER to continue...", 'bold'));
+}
+
+sub main_quit {
+ exit($_[0] // 0);
+}
+
+main_quit(0);