#!/usr/bin/perl # Copyright (C) 2010-2021 Trizen . # Copyright (C) 2020-2021 Jesus E. . # # 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: 30 October 2020 # Edit: 19 June 2021 # https://git.sr.ht/~heckyel/fair-viewer #------------------------------------------------------- # fair-viewer is a command line utility for streaming YouTube videos in mpv/vlc. # This is a fork of youtube-viewer: # https://github.com/trizen/youtube-viewer =encoding utf8 =head1 NAME fair-viewer - YouTube from command line. fair-viewer --help fair-viewer --tricks fair-viewer --examples fair-viewer --stdin-help =cut use utf8; use 5.016; use warnings; no warnings 'once'; my $DEVEL; # true in devel mode use if ($DEVEL = -w __FILE__), lib => qw(../lib); # devel mode use WWW::FairViewer v1.1.1; use WWW::FairViewer::RegularExpressions; require Storable; use File::Spec::Functions qw( catdir catfile curdir path rel2abs file_name_is_absolute ); binmode(STDOUT, ':utf8'); my $appname = 'CLI Fair Viewer'; my $version = $WWW::FairViewer::VERSION; my $execname = 'fair-viewer'; # A better 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 dirs my $config_dir = catdir($xdg_config_home, $execname); my $local_playlists_dir = catdir($config_dir, 'playlists'); # Configuration files my $config_file = catfile($config_dir, "$execname.conf"); my $youtube_users_file = catfile($config_dir, 'users.txt'); my $subscribed_channels_file = catfile($config_dir, 'subscribed_channels.txt'); 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'); # Special local playlists my $watch_history_data_file = catfile($local_playlists_dir, 'watched_videos.dat'); my $liked_videos_data_file = catfile($local_playlists_dir, 'liked_videos.dat'); my $disliked_videos_data_file = catfile($local_playlists_dir, 'disliked_videos.dat'); my $favorite_videos_data_file = catfile($local_playlists_dir, 'favorite_videos.dat'); my $subscription_videos_data_file = catfile($local_playlists_dir, "subscriptions.dat"); # Create the config and playlist dirs foreach my $dir ($config_dir, $local_playlists_dir) { if (not -d $dir) { require File::Path; File::Path::make_path($dir) or warn "[!] Can't create dir <<$dir>>: $!"; } } # Create the special playlist files foreach my $file ($watch_history_data_file, $liked_videos_data_file, $disliked_videos_data_file, $favorite_videos_data_file,) { if (not -s $file) { Storable::store([], $file); } } sub which_command { my ($cmd) = @_; if (file_name_is_absolute($cmd)) { return $cmd; } state $paths = [path()]; foreach my $path (@{$paths}) { my $cmd_path = catfile($path, $cmd); if (-f -x $cmd_path) { return $cmd_path; } } return; } # Main configuration my %CONFIG = ( video_players => { vlc => { cmd => q{vlc}, srt => q{--sub-file=*SUB*}, audio => q{--input-slave=*AUDIO*}, fs => q{--fullscreen}, arg => q{--quiet --play-and-exit --no-video-title-show --input-title-format=*TITLE* *VIDEO*}, novideo => q{--intf=dummy --novideo}, }, mpv => { cmd => q{mpv}, srt => q{--sub-file=*SUB*}, audio => q{--audio-file=*AUDIO*}, fs => q{--fullscreen}, arg => q{--really-quiet --force-media-title=*TITLE* --no-ytdl *VIDEO*}, novideo => q{--no-video}, }, }, video_player_selected => ( $constant{win32} ? 'vlc' : undef # auto-defined ), split_videos => 1, # YouTube options dash => 1, # may load slow maxResults => 20, hfr => 1, # true to prefer high frame rate (HFR) videos resolution => 'best', videoDefinition => undef, videoDimension => undef, videoLicense => undef, videoCaption => undef, videoDuration => undef, order => undef, date => undef, region => undef, # Comments order comments_order => 'top', # valid values: top, new # URI options youtube_video_url => 'https://www.youtube.com/watch?v=%s', # Subtitle options srt_languages => ['en', 'es'], get_captions => 1, auto_captions => 0, copy_caption => 0, cache_dir => undef, # API api_host => "auto", # Misc options autoplay_mode => 0, http_proxy => undef, cookie_file => undef, user_agent => undef, timeout => undef, env_proxy => 1, confirm => 0, debug => 0, page => 1, colors => $constant{win32} ^ 1, skip_if_exists => 1, prefer_mp4 => 0, prefer_m4a => 0, prefer_av1 => 0, ignore_av1 => 0, prefer_invidious => 0, fat32safe => $constant{win32}, fullscreen => 0, show_video_info => 1, interactive => 1, get_term_width => $constant{win32} ^ 1, download_with_wget => undef, # auto-defined thousand_separator => q{,}, downloads_dir => curdir(), download_and_play => 0, remove_played_file => 0, ignored_projections => [], # Conversion options convert_cmd => 'ffmpeg -i *IN* *OUT*', convert_to => undef, keep_original_video => 0, # Search history history => undef, # auto-defined history_limit => 100_000, history_file => $history_file, # Watch history watch_history => 1, watched_file => $watched_file, # Subscribed channels subscribed_channels_file => $subscribed_channels_file, subscriptions_limit => 10_000, # maximum number of subscription videos stored youtube_users_file => $youtube_users_file, # Options for watched videos highlight_watched => 1, highlight_color => 'bold', skip_watched => 0, # hypervideo support ytdl => 1, ytdl_cmd => undef, # auto-defined # Custom layout custom_layout_format => [{width => 3, align => "right", color => "bold", text => "*NO*.",}, {width => "55%", align => "left", color => "bold blue", text => "*TITLE*",}, {width => "15%", align => "left", color => "magenta", text => "*AUTHOR*",}, {width => 3, align => "right", color => "green", text => "*AGE_SHORT*",}, {width => 5, align => "right", color => "green", text => "*VIEWS_SHORT*",}, {width => 8, align => "right", color => "blue", text => "*TIME*",}, ], custom_channel_layout_format => [{width => 3, align => "right", color => "bold", text => "*NO*.",}, {width => "55%", align => "left", color => "bold blue", text => "*AUTHOR*",}, {width => 14, align => "right", color => "green", text => "*VIDEOS* videos",}, {width => 10, align => "right", color => "green", text => "*SUBS_SHORT* subs",}, ], custom_playlist_layout_format => [{align => "right", color => "bold", text => "*NO*.", width => 3}, {align => "left", color => "bold blue", text => "*TITLE*", width => "55%"}, {align => "right", color => "green", text => "*ITEMS* videos", width => 14}, {align => "left", color => "magenta", text => "*AUTHOR*", width => "20%"}, ], ffmpeg_cmd => 'ffmpeg', wget_cmd => 'wget', merge_into_mkv => undef, # auto-defined later merge_into_mkv_args => '-loglevel warning -c:s srt -c:v copy -c:a copy -disposition:s forced', merge_with_captions => 1, 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) : display the next page of results :r(eturn) : return to the previous page of results CONTROL my $other_options = <<'OTHER'; # Others :refresh : refresh the current list of results :dv=i : display the data structure of result i -argv -argv2=v : apply some arguments (e.g.: -u=google) :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 = <<"PLAYLISTS_HELP" . $general_help; # Select a playlist : list videos from the selected playlist :pp=i,i : play videos from the selected playlists PLAYLISTS_HELP my $channels_help = <<"CHANNELS_HELP" . $general_help; # Select a channel : latest uploads from channel :pv=i :popular=i : popular uploads from channel :p=i :playlists=i : playlists from channel # Save and remove channels :save=i : save channel :s=i :subscribe=i : subscribe to the channel :unsub=i : unsubscribe from the channel :r=i :remove=i : remove the channel CHANNELS_HELP my $comments_help = <<"COMMENTS_HELP" . $general_help; # Comments :c(omment) : send a comment to this video COMMENTS_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 :p(laylists)=i : display author's playlists :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 : 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 (defined($ENV{HOME}) and $home_dir eq $ENV{HOME}) { $dumped_config =~ s/\Q$home_dir\E/\$ENV{HOME}/g; } print $config_fh $config_documentation, $dumped_config; close $config_fh; } } our $CONFIG; 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, 'fair-viewer'); $update_config = 1; } # Locate video player if (not $CONFIG{video_player_selected}) { foreach my $key (sort keys %{$CONFIG{video_players}}) { if (defined(my $abs_player_path = which_command($CONFIG{video_players}{$key}{cmd}))) { $CONFIG{video_players}{$key}{cmd} = $abs_player_path; $CONFIG{video_player_selected} = $key; $update_config = 1; last; } } if (not $CONFIG{video_player_selected}) { warn "\n[!] Please install a supported video player! (e.g.: mpv)\n\n"; $CONFIG{video_player_selected} = 'mpv'; } } # Locate hypervideo if (not defined($CONFIG{ytdl_cmd})) { my $ytdl_path = which_command('hypervideo'); if (defined($ytdl_path)) { $CONFIG{ytdl_cmd} = $ytdl_path; } else { $CONFIG{ytdl_cmd} = 'hypervideo'; } $update_config = 1; } # Download with wget if it is installed if (not defined $CONFIG{download_with_wget}) { my $wget_path = which_command('wget'); if (defined($wget_path)) { $CONFIG{wget_cmd} = $wget_path; $CONFIG{download_with_wget} = 1; } else { $CONFIG{download_with_wget} = 0; } $update_config = 1; } # Merge into MKV if ffmpeg is installed if (not defined $CONFIG{merge_into_mkv}) { my $ffmpeg_path = which_command('ffmpeg'); if (defined($ffmpeg_path)) { $CONFIG{ffmpeg_cmd} = $ffmpeg_path; $CONFIG{merge_into_mkv} = 1; } else { $CONFIG{merge_into_mkv} = 0; } $update_config = 1; } # Enable history if Term::ReadLine::Gnu::XS is installed if (not defined $CONFIG{history}) { if (eval { $term->can('ReadHistory') }) { $CONFIG{history} = 1; } else { $CONFIG{history} = 0; } $update_config = 1; } foreach my $key (keys %CONFIG) { if (not exists $CONFIG->{$key}) { $update_config = 1; last; } } dump_configuration($config_file) if $update_config; # Create the cache directory (if needed) foreach my $path ($CONFIG{cache_dir}) { next if -d $path; require File::Path; File::Path::make_path($path) or warn "[!] Can't create path <<$path>>: $!"; } @opt{keys %CONFIG} = values(%CONFIG); } load_config($config_file); if ($opt{watch_history}) { if (-f $opt{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}) { require File::Basename; my $dir = File::Basename::dirname($opt{history_file}); if (not -d $dir) { require File::Path; File::Path::make_path($dir) or warn "[!] Can't create path <<$dir>>: $!"; } open my $fh, '>', $opt{history_file} or warn "[!] Can't create the history file `$opt{history_file}': $!"; } # Add history to Term::ReadLine eval { $term->ReadHistory($opt{history_file}) }; # All history entries my @history; eval { @history = $term->history_list }; # Rewrite the history file, when the history_limit has been reached. if ($opt{history_limit} > 0 and @history > $opt{history_limit}) { # Try to create a backup, first require File::Copy; File::Copy::cp($opt{history_file}, "$opt{history_file}.bak"); if (open my $fh, '>', $opt{history_file}) { # Keep only the most recent half part of the history file say {$fh} join("\n", @history[($opt{history_limit} >> 1) .. $#history]); close $fh; } } } my $yv_obj = WWW::FairViewer->new( escape_utf8 => 1, config_dir => $config_dir, ytdl => $opt{ytdl}, ytdl_cmd => $opt{ytdl_cmd}, cache_dir => $opt{cache_dir}, env_proxy => $opt{env_proxy}, cookie_file => $opt{cookie_file}, http_proxy => $opt{http_proxy}, user_agent => $opt{user_agent}, timeout => $opt{timeout}, ); require WWW::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); } #---------------------- FAIR-VIEWER USAGE ----------------------# sub help { my $eqs = q{=} x 30; local $" = ', '; print <<"HELP"; \n $eqs \U$appname\E $eqs usage: $execname [options] ([url] | [keywords]) == Base == [URL] : play an YouTube video by URL [keywords] : search for YouTube videos [playlist URL] : display a playlist of YouTube videos == YouTube Options == * Categories -c --categories : display the available YouTube categories * Region --region=s : set the region code (default: US) * Videos -uv --uploads=s : list videos uploaded by a specific channel or user -pv --popular=s : list the most popular videos from a specific channel -uf --favorites=s : list the favorite videos of 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) -wv --watched-videos : list the most recent watched videos -ls --local-subs : display subscription videos from local channels * Playlists -up --playlists=s : list playlists created by a specific channel or user -lp --local-playlists : display the list of local playlists -lp=s : display a local playlist by name -sp --search-pl : search for playlists of videos --pid=s : list a playlist of videos by playlist ID --pp=s,s : play the videos from the given playlist IDs * Trending --trending:s : show trending videos in a given category valid categories: music gaming news movies popular * Channels -sc --channels : search for YouTube channels * 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: short long --captions! : only videos with or without closed captions --order=s : order the results using a specific sorting method valid values: relevance rating upload_date view_count --time=s : short videos published in a time period valid values: hour today week month year --hd! : search only for videos available in at least 720p --vd=s : set the video definition (any or high) --dimension=s : set video dimension (any or 3d) --license=s : set video license (any or creative_commons) --page=i : get results starting with a specific page number --results=i : how many results to display per page (max: 50) --hfr! : prefer high frame rate (HFR) videos -2 -3 -4 -7 -1 : resolutions: 240p, 360p, 480p, 720p and 1080p -a --audio : prefer audio part only (implied by --novideo) --best : prefer best resolution available --resolution=s : supported resolutions: best, 2160p, 1440p, 1080p, 720p, 480p, 360p, 240p, 144p, audio. * Account --login : will prompt for authentication (OAuth 2.0) --logout : will delete the authentication key * Display local -P --playlists : show the local playlists -F --favorites : show the local favorite videos -lc --saved-channels : show the saved channels -S --subscriptions : show the subscribed channels -L --likes : show the videos that you liked -D --dislikes : show the videos that you disliked * Save local --save=s : save a given channel ID or username in -lc --subscribe=s : subscribe to a given channel ID or username --favorite=s : favorite a video by URL or ID --like=s : like a video by URL or ID (see: -L) --dislike=s : dislike a video by URL or ID (see: -D) == Player Options == * Arguments -f --fullscreen! : play videos in fullscreen mode -n --novideo : play audio only, without displaying video --append-arg=s : append some command-line parameters to the media player --player=s : select a player to stream videos available players: @{[keys %{$CONFIG->{video_players}}]} == Download Options == * Download -d --download! : activate the download mode -dp --dl-play! : play the video after download (with -d) -rp --rem-played! : delete a local video after played (with -dp) --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 --skip-watched! : don't play already watched videos --highlight! : remember and highlight selected videos --confirm! : show a confirmation message after each play --prefer-mp4! : prefer videos in MP4 format, instead of VP9 --prefer-av1! : prefer videos in AV1 format, instead of VP9 --prefer-m4a! : prefer audios in AAC format, instead of OPUS --ignore-av1! : ignore videos in AV1 format * Closed-captions --get-captions! : download closed-captions for videos --auto-captions! : include or exclude auto-generated captions --srt-languages=s : comma separated list of preferred languages * Config --config=s : configuration file --update-config! : update the configuration file * Output -i --info=s : show information for a video ID or URL -e --extract=s : extract information from videos (see: -T) --extract-file=s : extract information from videos in this file --dump=format : dump metadata information in `videoID.format` files valid formats: json, perl -q --quiet : do not display any warning --really-quiet : do not display any warning or output --video-info! : show video information before playing --escape-info! : quotemeta() the fields of the `--extract` --use-colors! : enable or disable the ANSI colors for text * Formatting --custom-layout=s : custom layout format for videos --custom-channel-layout=s : custom layout format for channels --custom-playlist-layout=s : custom layout format for playlists * Other --invidious! : prefer invidious instances over parsing YouTube --api=s : set an API host from https://api.invidious.io/ --api=auto : use a random instance of invidious --cookies=s : file to read cookies from and dump cookie --user-agent=s : specify a custom user agent --proxy=s : set HTTP(S)/SOCKS proxy: 'proto://domain.tld:port/' If authentication is required, use 'proto://user:pass\@domain.tld:port/' --split-videos! : include or exclude the itags for split videos --dash! : include or exclude segmented DASH streams --ytdl! : use hypervideo for videos with encrypted signatures `--no-ytdl` will use invidious instances --ytdl-cmd=s : hypervideo command (default: hypervideo) Help options: -T --tricks : show more 'hidden' features of $execname -E --examples : show several usage examples of $execname -H --stdin-help : show the valid stdin options for $execname -v --version : print version and exit -h --help : print help and exit --debug:1..3 : see behind the scenes NOTES: ! -> the argument can be negated with '--no-' =i -> requires an integer argument =s -> requires an argument :s -> can take an optional argument =s,s -> can take more arguments separated by commas HELP main_quit(0); } sub wrap_text { my (%args) = @_; require Text::Wrap; local $Text::Wrap::columns = ($args{columns} || $term_width) - 8; my $text = "@{$args{text}}"; $text =~ tr{\r}{}d; return eval { Text::Wrap::wrap($args{i_tab}, $args{s_tab}, $text) } // $text; } sub tricks { print <<"TRICKS"; == 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. > "6" (quoted) will search for videos with the keyword '6'. > If a stdin option is followed by one or more digits, the equal sign, which separates the option from value, can be omitted. Example: :i2,4 is equivalent with :i=2,4 :d1-5 is equivalent with :d=1,2,3,4,5 :c10 is equivalent with :c=10 > When more videos are selected to play, you can stop them by pressing CTRL+C. $execname will return to the previous section. > Space inside the values of STDIN options, can be either quoted or backslashed. Example: :re=video\\ title == :re="video title" > ":anp" stands for "Auto Next Page". How do we use it? Well, let's search for some videos. Now, if we want to play only the videos matched by a regex, we'd say :re="REGEX". But, what if we want to play the videos from the next pages too? In this case, ":anp" is your friend. Use it wisely! -> Special tokens: *ID* : the YouTube video ID *AUTHOR* : the author name of the video *CHANNELID* : the channel ID of the video *RESOLUTION* : the resolution of the video *VIEWS* : the number of views *VIEWS_SHORT* : the number of views in abbreviated notation *VIDEOS* : the number of channel videos *VIDEOS_SHORT* : the number of channel videos in abbreviated notation *SUBS* : the number of channel subscriptions *SUBS_SHORT* : the number of channel subscriptions in abbreviated notation *ITEMS* : the number of playlist items *ITEMS_SHORT* : the number of playlist items in abbreviated notation *LIKES* : the number of likes *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.hyperbola.info/donate/ TRICKS main_quit(0); } sub examples { print <<"EXAMPLES"; ==== COMMAND LINE EXAMPLES ==== Command: $execname -A -n russian music Results: play all the video results (-A) only audio, no video (-n) search for "russian music" Note: -A will include the videos from the next pages as well. Command: $execname --comments 'https://www.youtube.com/watch?v=U6_8oIPFREY' Results: show video comments for a specific video URL or videoID. Command: $execname --results=5 -up=khanacademy Results: the most recent 5 playlists by a specific author (-up). Command: $execname --author=MIT atom Results: search only in videos by a specific author. Command: $execname -S=vsauce Results: get the subscriptions for a username. Command: $execname cats --order=view_count --duration=short Results: search for 'cats' videos, ordered by ViewCount and short duration. Command: $execname -sc math lessons Results: search for YouTube channels. Command: $execname -uf=Google Results: show the latest favorite videos by a user. ==== USER INPUT EXAMPLES ==== A STDIN option can begin with ':', ';' or '='. Command: , :n, :next Results: get the next page of results. Command: :r, :return Results: return to the previous page of results. Command: :i4..6, :i7-9, :i20-4, :i2, :i=4, :info=4 Results: show extra information for the selected videos. Command: :d5,2, :d=3, :download=8 Results: download the selected videos. Command: :c2, :comments=4 Results: show comments for a selected video. Command: :r4, :related=6 Results: show related videos for a selected video. Command: :a14, :author=12 Results: show videos uploaded by the author who uploaded the selected video. Command: :p9, :playlists=14 Results: show playlists created by the author who uploaded the selected video. Command: :subscribe=7 Results: subscribe to the author's channel who uploaded the selected video. Command: :like=2, :dislike=4,5 Results: like or dislike the selected videos. Command: :fav=4, :favorite=3..5 Results: favorite the selected videos. Command: 3, 5..7, 12-1, 9..4, 2 3 9 Results: play the selected videos. Command: :q3,5, :q=4, :queue=3-9 Results: enqueue the selected videos to play them later. Command: :pq, :play-queue Results: play the videos enqueued by the :queue option. Command: :re="^Linux" Results: play videos matched by a regex. Example: matches title: "Linux video" Command: :regex="linux.*part \\d+/\\d+" Example: matches title: "Introduction to Linux (part 1/4)" Command: :anp 1 2 3 Results: play the first three videos from every page. Command: :r, :return Results: return to the previous section. EXAMPLES main_quit(0); } sub stdin_help { print $complete_help; main_quit(0); } # Print version sub version { print "$appname $version\n"; main_quit(0); } sub apply_configuration { my ($opt, $keywords) = @_; if ($yv_obj->get_debug >= 2 or (defined($opt->{debug}) && $opt->{debug} >= 2)) { require Data::Dump; say "=>> Options with keywords: <@{$keywords}>"; Data::Dump::pp($opt); } # ... BASIC OPTIONS ... # if (delete $opt->{quiet}) { close STDERR; } if (delete $opt->{really_quiet}) { close STDERR; close STDOUT; } # ... YOUTUBE OPTIONS ... # foreach my $option_name ( qw( videoCaption videoDefinition videoDimension videoDuration videoLicense maxResults api_host date order channelId region debug http_proxy page comments_order user_agent cookie_file timeout ytdl ytdl_cmd prefer_mp4 prefer_av1 prefer_invidious ) ) { 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->{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; }; } $yv_obj->set_channelId($id); } else { warn_invalid("username or channel ID", $name); } } 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}) { foreach my $channel_id (split(/[,\s]+/, delete $opt->{subscribe})) { subscribe_channel($channel_id); } } if (defined $opt->{save_channel}) { foreach my $channel_id (split(/[,\s]+/, delete $opt->{save_channel})) { save_channel($channel_id); } } if (defined $opt->{favorite_video}) { favorite_videos(split(/[,\s]+/, delete $opt->{favorite_video})); } if (defined $opt->{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->{saved_channels}) { print_saved_channels(delete $opt->{saved_channels}); } if (defined $opt->{subscribed_channels}) { print_subscribed_channels(delete $opt->{subscribed_channels}); } if (defined $opt->{local_playlist}) { print_local_playlist(delete $opt->{local_playlist}); } if (defined $opt->{playlist_id}) { my $playlistID = get_valid_playlist_id(delete($opt->{playlist_id})) // return; get_and_print_videos_from_playlist($playlistID); } if (delete $opt->{search_videos}) { print_videos($yv_obj->search_videos([@{$keywords}])); } if (delete $opt->{search_channels}) { print_channels($yv_obj->search_channels([@{$keywords}])); } if (delete $opt->{search_playlists}) { print_playlists($yv_obj->search_playlists([@{$keywords}])); } if (delete $opt->{categories}) { print_categories($yv_obj->video_categories); } if (delete $opt->{watched_videos}) { print_watched_videos(); } if (delete $opt->{local_subscriptions}) { print_local_subscription_videos(); } if (defined $opt->{uploads}) { my $str = delete $opt->{uploads}; if ($str) { if (my $id = extract_channel_id($str)) { $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 { warn_invalid("username or channel ID", $str); } } if (defined $opt->{popular_videos}) { my $str = delete $opt->{popular_videos}; if ($str eq '') { print_videos($yv_obj->trending_videos_from_category('popular')); } elsif (my $id = extract_channel_id($str)) { if (not $yv_utils->is_channelID($id)) { $id = $yv_obj->channel_id_from_username($id) // do { warn_invalid("username or channel ID", $id); undef; }; } print_videos($yv_obj->popular_videos($id)); } else { warn_invalid("username or channel ID", $str); } } if (defined $opt->{trending}) { my $cat_id = delete $opt->{trending}; print_videos($yv_obj->trending_videos_from_category($cat_id)); } if (defined $opt->{related_videos}) { get_and_print_related_videos(split(/[,\s]+/, delete($opt->{related_videos}))); } if (defined $opt->{playlists}) { my $str = delete($opt->{playlists}); if ($str) { if (my $id = extract_channel_id($str)) { $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_local_playlist(); } } 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_favorite_videos(); } } if (defined delete $opt->{likes}) { print_liked_videos(); } if (defined delete $opt->{dislikes}) { print_disliked_videos(); } if (defined $opt->{get_comments}) { get_and_print_comments(split(/[,\s]+/, delete($opt->{get_comments}))); } if (defined $opt->{print_video_info}) { get_and_print_video_info(split(/[,\s]+/, delete $opt->{print_video_info})); } } sub parse_arguments { my ($keywords) = @_; state $x = do { require Getopt::Long; Getopt::Long::Configure('no_ignore_case'); }; my %orig_opt = %opt; my $orig_config_file = "$config_file"; Getopt::Long::GetOptions( # Main options 'help|usage|h|?' => \&help, 'examples|E' => \&examples, 'stdin-help|shelp|sh|H' => \&stdin_help, 'tricks|tips|T' => \&tricks, 'version|v' => \&version, 'config=s' => \$config_file, 'update-config!' => sub { dump_configuration($config_file) }, # Resolutions 'audio|a' => sub { $opt{resolution} = 'audio' }, '144p' => sub { $opt{resolution} = 144 }, '240p|2' => sub { $opt{resolution} = 240 }, '360p|3' => sub { $opt{resolution} = 360 }, '480p|4' => sub { $opt{resolution} = 480 }, '720p|7' => sub { $opt{resolution} = 720 }, '1080p|1' => sub { $opt{resolution} = 1080 }, '1440p' => sub { $opt{resolution} = 1440 }, '2160p' => sub { $opt{resolution} = 2160 }, 'best' => sub { $opt{resolution} = 'best' }, 'hfr!' => \$opt{hfr}, 'res|resolution=s' => \$opt{resolution}, 'comments=s' => \$opt{get_comments}, 'comments-order=s' => \$opt{comments_order}, 'c|categories' => \$opt{categories}, 'video-ids|videoids|id|ids=s' => \$opt{play_video_ids}, 'lc|fc|local-channels|saved-channels:s' => \$opt{saved_channels}, 'subscriptions|sub-channels|S:s' => \$opt{subscribed_channels}, 'lp|local-playlists:s' => \$opt{local_playlist}, 'wv|watched-videos' => \$opt{watched_videos}, 'ls|local-subs|sub-videos|SV' => \$opt{local_subscriptions}, #'save-video|save=s' => \$opt{save_video}, 'save|save-channel=s' => \$opt{save_channel}, #'save-playlist=s' => \$opt{save_playlist}, 'search-videos|search|sv!' => \$opt{search_videos}, 'search-channels|channels|sc!' => \$opt{search_channels}, 'search-playlists|sp|p!' => \$opt{search_playlists}, '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}, 'likes|L|user-likes' => \$opt{likes}, 'dislikes|D' => \$opt{dislikes}, 'subscribe=s' => \$opt{subscribe}, 'trending|trends:s' => \$opt{trending}, 'playlist-id|pid=s' => \$opt{playlist_id}, # English-UK friendly 'favorite|favourite|favorite-video|favourite-video|fav=s' => \$opt{favorite_video}, 'login|authenticate' => \$opt{authenticate}, 'logout' => \$opt{logout}, 'related-videos|rv=s' => \$opt{related_videos}, 'popular-videos|popular|pv:s' => \$opt{popular_videos}, 'cookie-file|cookies=s' => \$opt{cookie_file}, 'user-agent|agent=s' => \$opt{user_agent}, 'http-proxy|https-proxy|proxy=s' => \$opt{http_proxy}, 'r|region|region-code=s' => \$opt{region}, 'order|order-by|sort|sort-by=s' => \$opt{order}, 'time|date=s' => \$opt{date}, 'duration=s' => \$opt{videoDuration}, 'max-seconds|max_seconds=i' => \$opt{max_seconds}, 'min-seconds|min_seconds=i' => \$opt{min_seconds}, 'like=s' => \$opt{like_video}, 'dislike=s' => \$opt{dislike_video}, 'author=s' => \$opt{author}, 'all|A|play-all!' => \$opt{play_all}, 'backwards|B!' => \$opt{play_backwards}, 'input|std-input=s' => \$opt{std_input}, 'use-colors|colors|colored!' => \$opt{colors}, 'autoplay!' => \$opt{autoplay_mode}, 'play-playlists|pp=s' => \$opt{play_playlists}, 'debug:1' => \$opt{debug}, 'download|dl|d!' => \$opt{download_video}, 'dimension=s' => \$opt{videoDimension}, 'license=s' => \$opt{videoLicense}, 'vd|video-definition=s' => \$opt{videoDefinition}, 'hd|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 'captions!' => \$opt{videoCaption}, 'fullscreen|fs|f!' => \$opt{fullscreen}, 'split-videos!' => \$opt{split_videos}, 'confirm!' => \$opt{confirm}, 'prefer-mp4!' => \$opt{prefer_mp4}, 'prefer-av1!' => \$opt{prefer_av1}, 'ignore-av1!' => \$opt{ignore_av1}, 'invidious|prefer-invidious!' => \$opt{prefer_invidious}, 'custom-layout-format=s' => \$opt{custom_layout_format}, 'custom-channel-layout-format=s' => \$opt{custom_channel_layout_format}, 'custom-playlist-layout-format=s' => \$opt{custom_playlist_layout_format}, 'merge-into-mkv|mkv-merge!' => \$opt{merge_into_mkv}, 'merge-with-captions|merge-captions!' => \$opt{merge_with_captions}, 'api-host|instance=s' => \$opt{api_host}, 'convert-command|convert-cmd=s' => \$opt{convert_cmd}, 'prefer-m4a!' => \$opt{prefer_m4a}, 'dash|dash-segmented!' => \$opt{dash}, 'wget-dl|wget-download!' => \$opt{download_with_wget}, 'filename|filename-format=s' => \$opt{video_filename_format}, 'rp|rem-played|remove-played-file!' => \$opt{remove_played_file}, 'info|i|video-info=s' => \$opt{print_video_info}, 'get-term-width!' => \$opt{get_term_width}, 'page=i' => \$opt{page}, 'novideo|no-video|n' => \$opt{novideo}, 'highlight!' => \$opt{highlight_watched}, 'skip-watched!' => \$opt{skip_watched}, 'results=i' => \$opt{maxResults}, 'shuffle|s!' => \$opt{shuffle}, 'pos|position=i' => \$opt{position}, 'ps|playlist-save=s' => \$opt{playlist_save}, 'ytdl!' => \$opt{ytdl}, 'ytdl-cmd=s' => \$opt{ytdl_cmd}, 'quiet|q!' => \$opt{quiet}, 'really-quiet!' => \$opt{really_quiet}, 'video-info!' => \$opt{show_video_info}, 'dp|downl-play|download-and-play|dl-play!' => \$opt{download_and_play}, 'thousand-separator=s' => \$opt{thousand_separator}, 'get-captions|get_captions!' => \$opt{get_captions}, 'auto-captions|auto_captions!' => \$opt{auto_captions}, 'srt-languages=s' => \$opt{srt_languages}, 'copy-caption|copy_caption!' => \$opt{copy_caption}, 'skip-if-exists|skip_if_exists!' => \$opt{skip_if_exists}, 'downloads-dir|download-dir=s' => \$opt{downloads_dir}, 'fat32safe!' => \$opt{fat32safe}, ) or warn "[!] Error in command-line arguments!\n"; if ($config_file ne $orig_config_file) { # load the config file specified with `--config=s` ##say ":: Loading config: $config_file"; $config_file = rel2abs($config_file); my %new_opt = %opt; load_config($config_file); foreach my $key (keys %new_opt) { if ( defined($new_opt{$key}) and defined($orig_opt{$key}) and $new_opt{$key} ne $orig_opt{$key}) { $opt{$key} = $new_opt{$key}; } } } apply_configuration(\%opt, $keywords); } # Parse the arguments if (@ARGV) { require Encode; @ARGV = map { Encode::decode_utf8($_) } @ARGV; parse_arguments(\@ARGV); } for (my $i = 0 ; $i <= $#ARGV ; $i++) { my $arg = $ARGV[$i]; next if 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 )); } else { main_quit($opt{_error} || 0); } sub get_valid_video_id { my ($value) = @_; my $id = $value =~ /$get_video_id_re/ ? $+{video_id} : $value =~ /$valid_video_id_re/ ? $value : undef; if (not defined $id) { warn_invalid('videoID', $value); return; } return $id; } sub get_valid_playlist_id { my ($value) = @_; my $id = $value =~ /$get_playlist_id_re/ ? $+{playlist_id} : $value =~ /$valid_playlist_id_re/ ? $value : undef; if (not defined $id) { warn_invalid('playlistID', $value); return; } return $id; } sub extract_channel_id { my ($str) = @_; if ($str =~ /$get_channel_videos_id_re/) { return $+{channel_id}; } if ($str =~ /$get_username_videos_re/) { return $+{username}; } if ($str =~ /$valid_channel_id_re/) { return $+{channel_id}; } if ($str =~ /^[-a-zA-Z0-9_]+\z/) { return $str; } return undef; } sub apply_input_arguments { my ($args, $keywords) = @_; if (@{$args}) { local @ARGV = @{$args}; parse_arguments($keywords); } return 1; } # Get term width sub get_term_width { return $term_width if $constant{win32}; $term_width = (-t STDOUT) ? ((split(q{ }, `stty size`))[1] || $term_width) : $term_width; } sub first_user_input { my @keys = get_input_for_first_time(); state $first_input_help = <<"HELP"; $base_options $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 //= ""; $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}) : ( do { my @lines = split(/\R/, $text); say for @lines[0 .. $#lines - 1]; $term->readline($lines[-1]); } // return ':return' ) ) =~ s/^\s+//r; return q{:next} if $input eq q{}; # for the next page require Encode; $input = Encode::decode_utf8($input); my ($args, $keywords) = parse_options($input); if ($opt{history}) { my $str = join(' ', grep { /\w/ } @{$args}, @{$keywords}); if ($str ne '' and $str !~ /^[0-9]{1,3}\z/) { $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 (@videos) = @_; foreach my $video_data (@videos) { prepend_video_data_to_file($video_data, $favorite_videos_data_file); } 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; my $file = ($rating eq 'like') ? $liked_videos_data_file : $disliked_videos_data_file; foreach my $video_data (@_) { prepend_video_data_to_file($video_data, $file); } return 1; } sub get_and_play_video_ids { (my @ids = grep { defined($_) } map { get_valid_video_id($_) } @_) || return; foreach my $id (@ids) { my $info = $yv_obj->video_details($id); if (ref($info) eq 'HASH' and keys %$info) { ## OK } else { $info->{title} = "unknown"; $info->{lengthSeconds} = 0; $info->{videoId} = $id; warn_cant_do('get info for', $id); } play_videos([$info]) || return; } return 1; } sub get_and_play_playlists { foreach my $id (@_) { my $videos = $yv_obj->videos_from_playlist_id(get_valid_playlist_id($id) // next); local $opt{play_all} = length($opt{std_input}) ? 0 : 1; print_videos($videos, auto => $opt{play_all}); } return 1; } sub get_and_print_video_info { foreach my $id (@_) { my $videoID = get_valid_video_id($id) // next; my $info = $yv_obj->video_details($videoID); if (ref($info) eq 'HASH' and keys %$info) { local $opt{show_video_info} = 1; print_video_info($info); } else { warn_cant_do('get info for', $videoID); } } return 1; } sub get_and_print_related_videos { foreach my $id (@_) { my $videoID = get_valid_video_id($id) // next; my $results = $yv_obj->related_to_videoID($videoID); print_videos($results); } return 1; } sub get_and_print_comments { foreach my $id (@_) { my $videoID = get_valid_video_id($id) // next; my $comments = $yv_obj->comments_from_video_id($videoID); print_comments($comments, $videoID); } return 1; } sub get_and_print_videos_from_playlist { my ($playlistID) = @_; if ($playlistID =~ /$valid_playlist_id_re/) { my $info = $yv_obj->videos_from_playlist_id($playlistID); if ($yv_utils->has_entries($info)) { print_videos($info); } else { warn colored("\n[!] Inexistent playlist...", 'bold red') . "\n"; return; } } else { warn_invalid('playlistID', $playlistID); return; } return 1; } sub _bold_color { my ($text) = @_; return colored($text, 'bold'); } sub youtube_urls { my ($arg) = @_; if ($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 = undef; my $has_token = 0; if (ref($info->{results}) eq 'HASH' and exists $info->{results}{continuation}) { $has_token = 1; $token = $info->{results}{continuation}; } if (not defined($option)) { return; } if ($option =~ /^(?:q|quit|exit)\z/) { main_quit(0); } elsif ($option =~ /^(?:n|next)\z/ and (defined($url) or ref($token) eq 'CODE')) { if ($has_token) { if (defined $token) { my $request = $yv_obj->next_page($url, $token); $callback->($request); } else { warn_last_page(); } } else { my $request = $yv_obj->next_page($url); $callback->($request); } } elsif ($option =~ /^(?:R|refresh)\z/ and defined($url)) { @{$results} = @{$yv_obj->_get_results($url)->{results}}; } elsif ($option 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[!] This is the first page!", '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 for the next page of comments (:h for help)") . "\n> "); } sub get_input_for_categories { return get_user_input(_bold_color("\n=>> Select a category (:h for help)") . "\n> "); } sub ask_yn { my (%opt) = @_; my $c = join('/', map { $_ eq $opt{default} ? ucfirst($_) : $_ } qw(y n)); my $answ; do { $answ = lc($term->readline($opt{prompt} . " [$c]: ")); $answ = $opt{default} unless $answ =~ /\S/; } while ($answ !~ /^y(?:es)?$/ and $answ !~ /^no?$/); return chr(ord($answ)) eq 'y'; } sub get_reply { my (%opt) = @_; my $default = 1; while (my ($i, $choice) = each @{$opt{choices}}) { print "\n" if $i == 0; printf("%3d> %s\n", $i + 1, $choice); if ($choice eq $opt{default}) { $default = $i + 1; } } print "\n"; my $answ; do { $answ = $term->readline($opt{prompt} . " [$default]: "); $answ = $default unless $answ =~ /\S/; } while ($answ !~ /^[0-9]+\z/ or $answ < 1 or $answ > @{$opt{choices}}); return $opt{choices}[$answ - 1]; } sub valid_num { my ($num, $array_ref) = @_; return $num =~ /^[0-9]{1,3}\z/ && $num != 0 && $num <= @{$array_ref}; } sub adjust_width { my ($str, $len, $prepend) = @_; if ($len <= 0) { return $str; } state $pkg = ( eval { require Unicode::GCString; 'Unicode::GCString'; } // eval { require Text::CharWidth; 'Text::CharWidth'; } // do { warn "[WARN] Please install Unicode::GCString or Text::CharWidth in order to use this functionality.\n"; ''; } ); my $adjust_str = sub { # Unicode::GCString if ($pkg eq 'Unicode::GCString') { my $gcstr = Unicode::GCString->new($str); my $str_width = $gcstr->columns; while ($str_width > $len) { $gcstr = $gcstr->substr(0, -1); $str_width = $gcstr->columns; } $str = $gcstr->as_string; return ($str, $str_width); } # Text::CharWidth if ($pkg eq 'Text::CharWidth') { my $str_width = Text::CharWidth::mbswidth($str); while ($str_width > $len) { chop $str; $str_width = Text::CharWidth::mbswidth($str); } return ($str, $str_width); } # Fallback to counting graphemes my @graphemes = $str =~ /(\X)/g; while (scalar(@graphemes) > $len) { pop @graphemes; } $str = join('', @graphemes); return ($str, scalar(@graphemes)); }; my ($new_str, $str_width) = $adjust_str->(); my $spaces = ' ' x ($len - $str_width); my $result = $prepend ? join('', $spaces, $new_str) : join('', $new_str, $spaces); return $result; } sub format_line_result { my ($i, $entry, $info, %args) = @_; if (ref($entry) eq '') { $entry =~ s/\*NO\*/sprintf('%2d', $i+1)/ge; $entry = $yv_utils->format_text( info => $info, text => $entry, escape => 0, ); return "$entry\n"; } if (ref($entry) eq 'ARRAY') { my @columns; foreach my $slot (@$entry) { my $text = $slot->{text}; my $width = $slot->{width} // 10; my $color = $slot->{color}; my $align = $slot->{align} // 'left'; if ($width =~ /^(\d+)%\z/) { $width = int(($term_width * $1) / 100); } $text =~ s/\*NO\*/$i+1/ge; $text = $yv_utils->format_text( info => $info, text => $text, escape => 0, ); $text = clear_title($text); $text = adjust_width($text, $width, ($align eq 'right')); if (defined($color)) { $text = colored($text, $color); } push @columns, $text; } return (join(' ', @columns) . "\n"); } die "ERROR: invalid custom layout format <<$entry>>\n"; } # ... PRINT SUBROUTINES ... # sub print_channels { my ($results) = @_; if (not $yv_utils->has_entries($results)) { warn_no_results("channel"); } if ($opt{get_term_width}) { get_term_width(); } my $url = $results->{url}; my $channels = $results->{results} // []; if (ref($channels) eq 'HASH') { if (exists $channels->{channels}) { $channels = $channels->{channels}; } elsif (exists $channels->{entries}) { $channels = $channels->{entries}; } else { warn "\n[!] No channels...\n"; $channels = []; } } my @formatted; foreach my $i (0 .. $#{$channels}) { my $channel = $channels->[$i]; my $entry = $opt{custom_channel_layout_format}; push @formatted, format_line_result($i, $entry, $channel); } if (@formatted) { print "\n" . join("", @formatted); } my @keywords = get_input_for_channels(); my @for_search; foreach my $key (@keywords) { if ($key =~ /$valid_opt_re/) { my $opt = $1; if ( general_options( opt => $opt, sub => __SUB__, url => $url, res => $channels, info => $results, ) ) { ## ok } # :h, :help elsif ($opt =~ /^(?:h|help)\z/) { print $channels_help; press_enter_to_continue(); } # :r, :return elsif ($opt =~ /^(?:r|return)\z/) { return; } # :i=i, :info=i elsif ($opt =~ /^(?:i|info)${digit_or_equal_re}(.*)/) { if (my @ids = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@ids) { print_channel_info($channels->[$id]); } press_enter_to_continue(); } else { warn_no_thing_selected('playlist'); } } # :pv=i, :popular=i elsif ($opt =~ /^(?:pv|popular)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $request = $yv_obj->popular_videos($channel_id); if ($yv_utils->has_entries($request)) { print_videos($request); } else { warn_no_results('popular video'); } } } else { warn_no_thing_selected('channel'); } } # :p=i, :playlist=i, :up=i elsif ($opt =~ /^(?:p|l|playlists?|up)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $request = $yv_obj->playlists($channel_id); if ($yv_utils->has_entries($request)) { print_playlists($request); } else { warn_no_results('playlist'); } } } else { warn_no_thing_selected('channel'); } } # :s=i, :subscribe=i elsif ($opt =~ /^(?:s|sub(?:scribe)?)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $channel_title = $yv_utils->get_channel_title($channels->[$id]); subscribe_channel($channel_id, $channel_title); } } else { warn_no_thing_selected('channel'); } } # :save=i elsif ($opt =~ /^(?:save)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($channels->[$id]); my $channel_title = $yv_utils->get_channel_title($channels->[$id]); save_channel($channel_id, $channel_title); } } else { warn_no_thing_selected('channel'); } } # :r=i, :rm=i, :remove=i, :unsubscribe=i elsif ($opt =~ /^(?:r|rm|remove)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { remove_saved_channels(map { $yv_utils->get_channel_id($channels->[$_]) } @nums); } else { warn_no_thing_selected('channel'); } } # :unsub=i elsif ($opt =~ /^(?:unsub(?:scribe)?)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$channels}, $1)) { unsubscribe_from_channels(map { $yv_utils->get_channel_id($channels->[$_]) } @nums); } else { warn_no_thing_selected('channel'); } } else { warn_invalid('option', $opt); } } elsif (youtube_urls($key)) { ## ok } elsif (valid_num($key, $channels)) { print_videos($yv_obj->uploads($yv_utils->get_channel_id($channels->[$key - 1]))); } else { push @for_search, $key; } } if (@for_search) { __SUB__->($yv_obj->search_channels(\@for_search)); } __SUB__->(@_); } sub print_comments { my ($results, $videoID) = @_; if (not $yv_utils->has_entries($results)) { warn_no_results("comments"); } my $url = $results->{url}; my $comments = $results->{results}{comments} // []; 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], ) ) { ## 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 _add_channel_to_file { my ($channel_id, $channel_title, $file) = @_; $channel_id // return; if ($channel_id = extract_channel_id($channel_id)) { if (not $yv_utils->is_channelID($channel_id)) { $channel_id = $yv_obj->channel_id_from_username($channel_id) // do { warn_invalid("username or channel ID", $channel_id); undef; }; } } $channel_id // return; $channel_title //= $yv_obj->channel_title_from_id($channel_id) // $channel_id; if (not defined($channel_title)) { warn "[!] Could not determine the channel name...\n"; return; } say ":: Saving channel <<$channel_title>> (id: $channel_id) to file..." if $yv_obj->get_debug; open(my $fh, '>>:utf8', $file) or do { warn "[!] Can't open file <<$file>> for appending: $!\n"; return; }; say $fh "$channel_id $channel_title"; close $fh; } sub save_channel { my ($channel_id, $channel_title) = @_; _add_channel_to_file($channel_id, $channel_title, $opt{youtube_users_file}); } sub subscribe_channel { my ($channel_id, $channel_title) = @_; _add_channel_to_file($channel_id, $channel_title, $opt{youtube_users_file}); _add_channel_to_file($channel_id, $channel_title, $opt{subscribed_channels_file}); } sub update_channel_file { my ($channels, $file) = @_; open(my $fh, '>:utf8', $file) or do { warn "[!] Can't open file <<$file>> for writing: $!\n"; return; }; foreach my $key (sort { CORE::fc($channels->{$a}) cmp CORE::fc($channels->{$b}) } keys %$channels) { say $fh "$key $channels->{$key}"; } close $fh; } sub _remove_channels_from_file { my ($channel_ids, $file) = @_; open(my $fh, '<:utf8', $file) or do { warn "[!] Can't open file <<$file>> for reading: $!\n"; return; }; my %channels; while (defined(my $line = <$fh>)) { if ($line =~ /^([a-zA-Z0-9_-]+) (.+)/) { my ($channel_id, $channel_title) = ($1, $2); if ($yv_utils->is_channelID($channel_id)) { $channels{$channel_id} = $channel_title; } else { warn "[!] Invalid channel ID: $channel_id\n"; } } } close $fh; my $removed = 0; foreach my $channel_id (@$channel_ids) { if (exists $channels{$channel_id}) { say ":: Removing: $channel_id" if $yv_obj->get_debug; delete $channels{$channel_id}; ++$removed; } else { say ":: $channel_id is not a saved channel..." if $yv_obj->get_debug; } } if ($removed > 0) { update_channel_file(\%channels, $file); } } sub remove_saved_channels { my (@channel_ids) = @_; _remove_channels_from_file(\@channel_ids, $opt{youtube_users_file}); _remove_channels_from_file(\@channel_ids, $opt{subscribed_channels_file}); } sub unsubscribe_from_channels { my (@channel_ids) = @_; _remove_channels_from_file(\@channel_ids, $opt{subscribed_channels_file}); } sub get_results_from_list { my ($results, %args) = @_; $args{page} //= $yv_obj->get_page; if (ref($results) ne 'ARRAY') { return; } my @results = @$results; my $maxResults = $yv_obj->get_maxResults; my $totalResults = scalar(@results); if ($args{page} >= 1 and scalar(@results) >= $maxResults) { @results = grep { defined } @results[($args{page} - 1) * $maxResults .. $args{page} * $maxResults - 1]; if (!@results) { warn_last_page() if ($args{page} == 1 + sprintf('%0.f', 0.5 + $totalResults / $maxResults)); return __SUB__->($results, %args, page => $args{page} - 1) if ($args{page} > 1); } } my %results; my @entries; foreach my $entry (@results) { if (defined($args{callback})) { push @entries, $args{callback}($entry); } else { push @entries, $entry; } } #<<< $results{entries} = \@entries; #$results{pageInfo} = {resultsPerPage => scalar(@entries), totalResults => $totalResults}; #$results{fromPage} = sub { get_results_from_list($results, %args, page => $_[0]) }; $results{continuation} = sub { get_results_from_list($results, %args, page => ($args{page} + 1)) }; #$results{prevPageToken} = sub { get_results_from_list($results, %args, page => (($args{page} > 1) ? ($args{page} - 1) : do { warn_first_page(); 1 })) }; #>>> scalar {results => \%results, url => undef}; } sub print_local_playlist { my ($name) = @_; $name //= ''; require File::Basename; my @playlist_files = reverse $yv_utils->get_local_playlist_filenames($local_playlists_dir); my $regex = qr/\Q$name\E/i; if ($name eq '') { my $results = get_results_from_list( \@playlist_files, callback => sub { my ($id) = @_; $yv_utils->local_playlist_snippet($id); } ); return print_playlists($results); } foreach my $file (@playlist_files) { if (File::Basename::basename($file) =~ $regex or $file eq $name) { return print_videos_from_data_file($file); } } warn_no_thing_selected('playlist'); return 0; } sub print_videos_from_data_file { my ($file) = @_; my $videos = eval { Storable::retrieve($file) } // []; print_videos(get_results_from_list($videos)); } sub print_watched_videos { print_videos_from_data_file($watch_history_data_file); } sub print_liked_videos { print_videos_from_data_file($liked_videos_data_file); } sub print_disliked_videos { print_videos_from_data_file($disliked_videos_data_file); } sub print_favorite_videos { print_videos_from_data_file($favorite_videos_data_file); } sub print_subscription_videos { print_videos_from_data_file($subscription_videos_data_file); } sub print_local_subscription_videos { # Reuse the subscription file if it's less than 10 minutes old if (-f $subscription_videos_data_file and (-M _) < (1 / 6) / 24 and (-M _) < ((-M $opt{subscribed_channels_file}) // 0)) { return print_subscription_videos(); } my @channels = $yv_utils->read_channels_from_file($opt{subscribed_channels_file}); if (not @channels) { warn "\n[!] No subscribed channels...\n"; return; } my %subscriptions; print "\n" if @channels; require Time::Piece; my $time = Time::Piece->new(); my @items; foreach my $i (0 .. $#channels) { local $| = 1; printf("[%d/%d] Retrieving info for $channels[$i][1]...\r", $i + 1, $#channels + 1); my $id = $channels[$i][0] // next; my $uploads = $yv_obj->uploads($id) // next; my $videos = $uploads->{results} // []; if (ref($videos) eq 'HASH' and exists $videos->{videos}) { $videos = $videos->{videos}; } if (ref($videos) eq 'HASH' and exists $videos->{entries}) { $videos = $videos->{entries}; } if (ref($videos) ne 'ARRAY') { next; } $subscriptions{$id} = 1; $subscriptions{lc($id)} = 1; foreach my $video (@$videos) { $video->{timestamp} = [@$time]; } push @items, @$videos; } print "\n" if @items; my $subscriptions_data = []; if (-f $subscription_videos_data_file) { $subscriptions_data = eval { Storable::retrieve($subscription_videos_data_file) } // []; } unshift(@$subscriptions_data, @items); # Remove duplicates @$subscriptions_data = do { my %seen; grep { !$seen{$yv_utils->get_video_id($_)}++ } @$subscriptions_data; }; # Remove videos from unsubscribed channels @$subscriptions_data = grep { exists($subscriptions{$yv_utils->get_channel_id($_)}) or exists($subscriptions{lc($yv_utils->get_channel_title($_) // '')}) } @$subscriptions_data; # Order videos by newest first @$subscriptions_data = map { $_->[0] } sort { $b->[1] <=> $a->[1] } map { [$_, $yv_utils->get_publication_time($_)] } @$subscriptions_data; # Remove results from the end when the list becomes too large my $subscriptions_limit = $opt{subscriptions_limit} // 1e4; if ($subscriptions_limit > 0 and scalar(@$subscriptions_data) > $subscriptions_limit) { $#$subscriptions_data = $subscriptions_limit; } if (@$subscriptions_data) { Storable::store($subscriptions_data, $subscription_videos_data_file); } print_videos(get_results_from_list($subscriptions_data)); } sub _print_local_channel_from_file { my ($name, $file) = @_; $name //= ''; my @users = $yv_utils->read_channels_from_file($file); my $regex = qr/\Q$name\E/i; if ($name eq '') { my $results = get_results_from_list( \@users, callback => sub { my ($entry) = @_; my ($id, $name) = @$entry; $yv_utils->local_channel_snippet($id, $name); } ); return print_channels($results); } foreach my $user (@users) { my ($channel_id, $channel_name) = @$user; if ($channel_id eq $name or $channel_name =~ $regex) { return print_videos($yv_obj->uploads($channel_id)); } } warn_no_thing_selected('channel'); return 0; } sub print_saved_channels { my ($name) = @_; _print_local_channel_from_file($name, $opt{youtube_users_file}); } sub print_subscribed_channels { my ($name) = @_; _print_local_channel_from_file($name, $opt{subscribed_channels_file}); } sub print_categories { my ($results) = @_; my $categories = $results; return if ref($categories) ne 'ARRAY'; my $i = 0; print "\n" if @{$categories}; foreach my $category (@{$categories}) { printf "%s. %-40s\n", colored(sprintf('%2d', ++$i), 'bold'), $category->{title}; } my @keywords = get_input_for_categories(); foreach my $key (@keywords) { if ($key =~ /$valid_opt_re/) { my $opt = $1; if ( general_options( opt => $opt, sub => __SUB__, res => $results, ) ) { ## ok } elsif ($opt =~ /^(?:h|help)\z/) { print $general_help; press_enter_to_continue(); } elsif ($opt =~ /^(?:r|return)\z/) { return; } else { warn_invalid('option', $opt); } } elsif (youtube_urls($key)) { ## ok } elsif (valid_num($key, $categories)) { my $category = $categories->[$key - 1]; my $cat_id = $category->{id}; my $videos = $yv_obj->trending_videos_from_category($cat_id); print_videos($videos); } else { warn_invalid('keyword', $key); } } __SUB__->(@_); } sub print_playlists { my ($results, %args) = @_; if (not $yv_utils->has_entries($results)) { warn_no_results("playlist"); } if ($opt{get_term_width}) { get_term_width(); } my $url = $results->{url}; my $playlists = $results->{results} // []; if (ref($playlists) eq 'HASH') { if (exists $playlists->{playlists}) { $playlists = $playlists->{playlists}; } elsif (exists $playlists->{entries}) { $playlists = $playlists->{entries}; } else { warn "\n[!] No playlists...\n"; $playlists = []; } } my @formatted; foreach my $i (0 .. $#{$playlists}) { my $playlist = $playlists->[$i]; my $entry = $opt{custom_playlist_layout_format}; push @formatted, format_line_result($i, $entry, $playlist); } if (@formatted) { print "\n" . join("", @formatted); } state @keywords; if ($args{auto}) { } # do nothing... else { @keywords = get_input_for_playlists(); if (scalar(@keywords) == 0) { __SUB__->(@_); } } my $contains_keywords = grep /$non_digit_or_opt_re/, @keywords; my @for_search; foreach my $key (@keywords) { if ($key =~ /$valid_opt_re/) { my $opt = $1; if ( general_options( opt => $opt, sub => __SUB__, url => $url, res => $playlists, info => $results, mode => 'playlists', ) ) { ## ok } elsif ($opt =~ /^(?:h|help)\z/) { print $playlists_help; press_enter_to_continue(); } elsif ($opt =~ /^(?:r|return)\z/) { return; } # :i=i, :info=i elsif ($opt =~ /^(?:i|info)${digit_or_equal_re}(.*)/) { if (my @ids = get_valid_numbers($#{$playlists}, $1)) { foreach my $id (@ids) { print_playlist_info($playlists->[$id]); } press_enter_to_continue(); } else { warn_no_thing_selected('playlist'); } } # :pp=i elsif ($opt =~ /^pp${digit_or_equal_re}(.*)/) { if (my @ids = get_valid_numbers($#{$playlists}, $1)) { my $arg = "--pp=" . join(q{,}, map { $yv_utils->get_playlist_id($_) } @{$playlists}[@ids]); apply_input_arguments([$arg]); } else { warn_no_thing_selected('playlist'); } } else { warn_invalid('option', $opt); } } elsif (youtube_urls($key)) { ## ok } elsif (valid_num($key, $playlists) and not $contains_keywords) { my $id = $yv_utils->get_playlist_id($playlists->[$key - 1]); if ($args{return_playlist_id}) { return $id; } if ($id =~ m{^/}) { # local playlist print_local_playlist($id); } else { get_and_print_videos_from_playlist($id); } } else { push @for_search, $key; } } if (@for_search) { __SUB__->($yv_obj->search_playlists(\@for_search)); } __SUB__->(@_); } sub compile_regex { my ($value) = @_; $value =~ s{^(?['"])(?.+)\g{quote}$}{$+{regex}}s; my $re = eval { use re qw(eval); qr/$value/i }; if ($@) { warn_invalid("regex", $@); return; } return $re; } sub get_range_numbers { my ($first, $second) = @_; return ( $first > $second ? (reverse($second .. $first)) : ($first .. $second) ); } sub get_valid_numbers { my ($max, $input) = @_; my @output; foreach my $id (split(/[,\s]+/, $input)) { push @output, $id =~ /$range_num_re/ ? get_range_numbers($1, $2) : $id =~ /^[0-9]{1,3}\z/ ? $id : next; } return grep { $_ >= 0 and $_ <= $max } map { $_ - 1 } @output; } sub get_streaming_url { my ($video_id) = @_; my ($urls, $captions, $info) = $yv_obj->get_streaming_urls($video_id); if (not defined $urls) { return scalar {}; } # Download the closed-captions my $srt_file; if (ref($captions) eq 'ARRAY' and @$captions and $opt{get_captions} and not $opt{novideo}) { require WWW::FairViewer::GetCaption; my $languages = $opt{srt_languages}; if (ref($languages) ne 'ARRAY') { $languages = [grep { /[a-z]/i } split(/\s*,\s*/, $languages)]; } my $yv_cap = WWW::FairViewer::GetCaption->new( auto_captions => $opt{auto_captions}, captions_dir => $opt{cache_dir}, captions => $captions, languages => $languages, yv_obj => $yv_obj, ); $srt_file = $yv_cap->save_caption($video_id); } require WWW::FairViewer::Itags; state $yv_itags = WWW::FairViewer::Itags->new(); # Include split-videos my $split_videos = 1; # Exclude split-videos in download-mode or when no video output is required if ($opt{novideo} or not $opt{split_videos}) { $split_videos = 0; } elsif ($opt{download_video}) { $split_videos = $opt{merge_into_mkv} ? 1 : 0; } my ($streaming, $resolution) = $yv_itags->find_streaming_url( urls => $urls, resolution => ($opt{novideo} ? 'audio' : $opt{resolution}), hfr => $opt{hfr}, ignore_av1 => $opt{ignore_av1}, split => $split_videos, prefer_m4a => $opt{prefer_m4a}, dash => ($opt{download_video} ? 0 : $opt{dash}), ignored_projections => $opt{ignored_projections}, ); return { streaming => $streaming, srt_file => $srt_file, info => $info, resolution => $resolution, }; } sub download_from_url { my ($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); if ($? == 256 and !defined(fileno(STDOUT))) { # lwp-download bug ## ok } else { return if $?; } rename("$output_filename.part", $output_filename) or return undef; return $output_filename; } # Download with LWP::UserAgent require LWP::UserAgent; my $lwp = LWP::UserAgent->new( show_progress => 1, agent => 'Mozilla/5.0 (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 $video_filename = $yv_utils->format_text( streaming => $streaming, info => $info, text => $opt{video_filename_format}, escape => 0, fat32safe => $opt{fat32safe}, ); my $naked_filename = $video_filename =~ s/\.\w+\z//r; $naked_filename =~ s/\h*:+\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 = catfile($opt{downloads_dir}, "$naked_filename.$opt{convert_to}"); my $convert_cmd = $opt{convert_cmd}; my %table = ( 'IN' => $video_filename, 'OUT' => $convert_filename, ); my $regex = do { local $" = '|'; qr/\*(@{[keys %table]})\*/; }; $convert_cmd =~ s/$regex/\Q$table{$1}\E/g; say $convert_cmd if $yv_obj->get_debug; $yv_obj->proxy_system($convert_cmd); if ($? == 0) { if (not $opt{keep_original_video}) { unlink $video_filename or warn colored("\n[!] Can't unlink file '$video_filename': $!", 'bold red') . "\n\n"; } $video_filename = $convert_filename if -e $convert_filename; } } # Play the download video if ($opt{download_and_play}) { local $streaming->{streaming}{url} = ''; local $streaming->{streaming}{__AUDIO__} = undef; local $streaming->{srt_file} = undef if ($opt{merge_into_mkv} && $opt{merge_with_captions}); my $command = get_player_command($streaming, $info); say "-> Command: ", $command if $yv_obj->get_debug; $yv_obj->proxy_system(join(q{ }, $command, quotemeta($video_filename))); # Remove it afterwards if ($? == 0 and $opt{remove_played_file}) { unlink $video_filename or warn colored("\n[!] Can't unlink file '$video_filename': $!", 'bold red') . "\n\n"; } } # Copy the .srt file to downloads-dir if ( $opt{copy_caption} and -e $video_filename and defined($streaming->{srt_file}) and -e $streaming->{srt_file}) { my $from = $streaming->{srt_file}; my $to = $srt_filename; require File::Copy; File::Copy::cp($from, $to); } return 1; } sub prepend_video_data_to_file { my ($video_data, $file) = @_; my $videos = eval { Storable::retrieve($file) } // []; if (ref($video_data) ne 'HASH') { my $videoID = get_valid_video_id($video_data) // return; $video_data = $yv_obj->video_details($videoID); } get_valid_video_id($yv_utils->get_video_id($video_data)) // return; unshift(@$videos, $video_data); my %seen; @$videos = grep { !$seen{$yv_utils->get_video_id($_)}++ } @$videos; Storable::store($videos, $file); return 1; } sub save_watched_video { my ($video_id, $video_data) = @_; if ($opt{watch_history}) { if (not exists($WATCHED_VIDEOS{$video_id})) { $WATCHED_VIDEOS{$video_id} = 1; open my $fh, '>>', $opt{watched_file} or return; say {$fh} $video_id; close $fh; } prepend_video_data_to_file($video_data, $watch_history_data_file); } $WATCHED_VIDEOS{$video_id} = 1; return 1; } sub get_player_command { my ($streaming, $video) = @_; $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{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 =~ /(?:^|\s)\*(?:VIDEO|URL)\*(?:\s|\z)/; $cmd = $yv_utils->format_text( streaming => $streaming, info => $video, text => $cmd, escape => 1, ); if ($streaming->{streaming}{url} =~ m{^https://www\.youtube\.com/watch\?v=}) { $cmd =~ s{\s*--no-ytdl\b}{ }g; } $has_video ? $cmd : join(' ', $cmd, quotemeta($streaming->{streaming}{url})); } sub autoplay { my $video_id = get_valid_video_id(shift) // return; my %seen = ($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}}) || 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, $video); press_enter_to_continue() if $opt{confirm}; } return 1; } sub play_videos_matched_by_regex { my %args = @_; my $key = $args{key}; my $regex = $args{regex}; my $videos = $args{videos}; my $sub = \&{'WWW::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_playlist_info { my ($playlist) = @_; my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width); printf( "\n%s\n%s\n%s\n", _bold_color('=> Description'), $hr, wrap_text( i_tab => q{}, s_tab => q{}, text => [$yv_utils->get_description($playlist) || 'No description available...'] ), ); my $id = $yv_utils->get_playlist_id($playlist); if ($id =~ m{^/}) { ## local playlist } else { say STDOUT $hr, "\n", _bold_color('=> URL: '), sprintf("https://www.youtube.com/playlist?list=%s", $id); } my $title = $yv_utils->get_title($playlist); my $title_length = length($title); my $rep = ($term_width - $title_length) / 2 - 4; $rep = 0 if $rep < 0; print( "$hr\n", q{ } x $rep => (_bold_color("=>> $title <<=") . "\n\n"), ( map { sprintf(q{-> } . "%-*s: %s\n", $opt{_colors} ? 18 : 10, _bold_color($_->[0]), $_->[1]) } grep { defined($_->[1]) } ( ['Title' => $yv_utils->get_title($playlist)], ['Author' => $yv_utils->get_channel_title($playlist)], ['ChannelID' => $yv_utils->get_channel_id($playlist)], ['PlaylistID' => ($id =~ m{^/} ? undef : $id)], ['Videos' => $yv_utils->set_thousands($yv_utils->get_playlist_item_count($playlist))], ['Published' => $yv_utils->get_publication_date($playlist)], ) ), "$hr\n" ); return 1; } sub print_channel_info { my ($channel) = @_; my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width); printf( "\n%s\n%s\n%s\n%s\n%s", _bold_color('=> Description'), $hr, wrap_text( i_tab => q{}, s_tab => q{}, text => [$yv_utils->get_description($channel) || 'No description available...'] ), $hr, _bold_color('=> URL: ') ); print STDOUT sprintf("https://www.youtube.com/channel/%s", $yv_utils->get_channel_id($channel)); my $title = $yv_utils->get_channel_title($channel); my $title_length = length($title); my $rep = ($term_width - $title_length) / 2 - 4; $rep = 0 if $rep < 0; print( "\n$hr\n", q{ } x $rep => (_bold_color("=>> $title <<=") . "\n\n"), ( map { sprintf(q{-> } . "%-*s: %s\n", $opt{_colors} ? 20 : 12, _bold_color($_->[0]), $_->[1]) } grep { defined($_->[1]) } ( ['Channel' => $title], ['ChannelID' => $yv_utils->get_channel_id($channel)], ['Videos' => $yv_utils->set_thousands($yv_utils->get_channel_video_count($channel))], ['Subscribers' => $yv_utils->set_thousands($yv_utils->get_channel_subscriber_count($channel))], ['Published' => $yv_utils->get_publication_date($channel)], ) ), "$hr\n" ); return 1; } sub print_video_info { my ($video) = @_; $opt{show_video_info} || return 1; my $extra_info = $yv_obj->video_details($yv_utils->get_video_id($video) // return 1); foreach my $key (keys %$extra_info) { $video->{$key} = $extra_info->{$key}; } my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width); printf( "\n%s\n%s\n%s\n%s\n%s", _bold_color('=> Description'), $hr, wrap_text( i_tab => q{}, s_tab => q{}, text => [$yv_utils->get_description($video) || 'No description available...'] ), $hr, _bold_color('=> URL: ') ); print STDOUT sprintf($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]) } grep { defined($_->[1]) and $_->[1] !~ /^(0|unknown)\z/i } ( ['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)], ['Rating' => $yv_utils->get_rating($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))], ['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) = @_; if (not $yv_utils->has_entries($results)) { warn_no_results("video"); } if ($opt{get_term_width}) { get_term_width(); } my $url = $results->{url}; my $videos = $results->{results} // []; if (ref($videos) eq 'HASH' and exists $videos->{videos}) { $videos = $videos->{videos}; } if (ref($videos) eq 'HASH' and exists $videos->{entries}) { $videos = $videos->{entries}; } my $token = undef; if (ref($results->{results}) eq 'HASH' and exists $results->{results}{continuation}) { $token = $results->{results}{continuation}; } if (ref($videos) ne 'ARRAY') { say "\n:: Probably the selected invidious instance is down. Try:"; say "\n\t$0 --api=auto\n"; say "See also: https://git.sr.ht/~heckyel/fair-viewer#invidious-instances"; return; } #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]; my $entry = $opt{custom_layout_format}; push @formatted, format_line_result($i, $entry, $video); } if ($opt{highlight_watched}) { foreach my $i (0 .. $#{$videos}) { my $video = $videos->[$i]; if (exists($WATCHED_VIDEOS{$yv_utils->get_video_id($video)})) { $formatted[$i] = colored(colorstrip($formatted[$i]), $opt{highlight_color}); } } } if (@formatted) { print "\n" . join("", @formatted); } if ($opt{play_all} || $opt{play_backwards}) { if (@{$videos}) { if ( play_videos( $opt{play_backwards} ? [reverse @{$videos}] : $videos ) ) { if ($opt{play_backwards}) { if (defined($url)) { return; } else { $opt{play_backwards} = 0; warn_first_page(); return; } } else { if (defined($url) or ref($token) eq 'CODE') { __SUB__->($yv_obj->next_page($url, $token), auto => 1); } else { $opt{play_all} = 0; warn_last_page(); return; } } } else { $opt{play_all} = 0; $opt{play_backwards} = 0; __SUB__->($results); } } else { $opt{play_all} = 0; $opt{play_backwards} = 0; } } state @keywords; if ($args{auto}) { } # do nothing... else { @keywords = get_input_for_search(); if (scalar(@keywords) == 0) { # only arguments __SUB__->($results); } } state @for_search; state @for_play; my @copy_of_keywords = @keywords; my $contains_keywords = grep /$non_digit_or_opt_re/, @keywords; while (@keywords) { my $key = shift @keywords; if ($key =~ /$valid_opt_re/) { my $opt = $1; if ( general_options(opt => $opt, res => $videos,) ) { ## ok } elsif ($opt =~ /^(?:h|help)\z/) { print $complete_help; press_enter_to_continue(); } elsif ($opt =~ /^(?:n|next)\z/) { if (defined($url) or ref($token) eq 'CODE') { my $request = $yv_obj->next_page($url, $token); __SUB__->($request, @keywords ? (auto => 1) : ()); } else { warn_last_page(); if ($opt{auto_next_page}) { $opt{auto_next_page} = 0; @copy_of_keywords = (); last; } } } # :refresh elsif ($opt =~ /^(?:R|refresh)\z/) { @{$videos} = @{$yv_obj->_get_results($url)->{results}}; $results->{has_extra_info} = 0; } # :r, :return elsif ($opt =~ /^(?:r|return)\z/) { return; } # :author=i, :u=i elsif ($opt =~ /^(?:a|author|u|uploads)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $request = $yv_obj->uploads($channel_id); if ($yv_utils->has_entries($request)) { __SUB__->($request); } else { warn_no_results('video'); } } } else { warn_no_thing_selected('video'); } } # :s=i, :subscribe=i elsif ($opt =~ /^(?:s|sub(?:scribe)?)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $channel_title = $yv_utils->get_channel_title($videos->[$id]); subscribe_channel($channel_id, $channel_title); } } else { warn_no_thing_selected('video'); } } # :save=i elsif ($opt =~ /^(?:save)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $channel_title = $yv_utils->get_channel_title($videos->[$id]); save_channel($channel_id, $channel_title); } } else { warn_no_thing_selected('video'); } } # :popular=i, :pv=i elsif ($opt =~ /^(?:pv|popular)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $channel_id = $yv_utils->get_channel_id($videos->[$id]); my $request = $yv_obj->popular_videos($channel_id); if ($yv_utils->has_entries($request)) { __SUB__->($request); } else { warn_no_results('popular video'); } } } else { warn_no_thing_selected('video'); } } # :p=i, :playlist=i, :up=i elsif ($opt =~ /^(?:p|l|playlists?|up)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $id (@nums) { my $request = $yv_obj->playlists($yv_utils->get_channel_id($videos->[$id])); if ($yv_utils->has_entries($request)) { print_playlists($request); } else { warn_no_results('playlist'); } } } else { warn_no_thing_selected('video'); } } # :like=i, :dislike=i elsif ($opt =~ /^((?:dis)?like)${digit_or_equal_re}(.*)/) { my $rating = $1; if (my @nums = get_valid_numbers($#{$videos}, $2)) { rate_videos($rating, map { $videos->[$_] } @nums); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:fav|favorite|F)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { favorite_videos(map { $videos->[$_] } @nums); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:q|queue|enqueue)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { push @{$opt{_queue_play}}, map { $yv_utils->get_video_id($videos->[$_]) } @nums; } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:pq|qp|play-queue)\z/) { if (ref $opt{_queue_play} eq 'ARRAY' and @{$opt{_queue_play}}) { my $ids = 'v=' . join(q{,}, splice @{$opt{_queue_play}}); general_options(opt => $ids); } else { warn colored("\n[!] The playlist is empty!", 'bold red') . "\n"; } } elsif ($opt =~ /^c(?:omments?)?${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { get_and_print_comments(map { $yv_utils->get_video_id($videos->[$_]) } @nums); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^r(?:elated)?${digit_or_equal_re}(.*)/) { if (my ($id) = get_valid_numbers($#{$videos}, $1)) { get_and_print_related_videos($yv_utils->get_video_id($videos->[$id])); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:ap|autoplay)${digit_or_equal_re}(.*)/) { if (my ($id) = get_valid_numbers($#{$videos}, $1)) { local $opt{autoplay_mode} = 1; play_videos([$videos->[$id]]); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^d(?:ownload)?${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { local $opt{download_video} = 1; play_videos([@{$videos}[@nums]]); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^(?:play|P)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { local $opt{download_video} = 0; local $opt{extract_info} = undef; play_videos([@{$videos}[@nums]]); } else { warn_no_thing_selected('video'); } } elsif ($opt =~ /^i(?:nfo)?${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { foreach my $num (@nums) { local $opt{show_video_info} = 1; print_video_info($videos->[$num]); } press_enter_to_continue(); } else { warn_no_thing_selected('video'); } } elsif ($opt eq 'anp') { # auto-next-page $opt{auto_next_page} = 1; } elsif ($opt eq 'nnp') { # no-next-page $opt{auto_next_page} = 0; } elsif ($opt =~ /^[ks]re(?:gex)?=(.*)/) { my $value = $1; if ($value =~ /^([a-zA-Z]++)(?>,|=>)(.+)/) { play_videos_matched_by_regex( key => $1, regex => $2, videos => $videos, ) or __SUB__->($results); } else { warn_invalid("Special Regexp", $value); } } elsif ($opt =~ /^re(?:gex)?=(.*)/) { play_videos_matched_by_regex( key => 'title', regex => $1, videos => $videos, ) or __SUB__->($results); } else { warn_invalid('option', $opt); } } elsif (youtube_urls($key)) { ## ok } elsif (!$contains_keywords and (valid_num($key, $videos) or $key =~ /$range_num_re/)) { my @for_play; if ($key =~ /$range_num_re/) { my $from = $1; my $to = $2 // do { $opt{auto_next_page} ? do { $from = 1 } : do { $opt{auto_next_page} = 1 }; $#{$videos} + 1; }; my @ids = get_valid_numbers($#{$videos}, "$from..$to"); if (@ids) { push @for_play, @ids; } else { push @for_search, $key; } } else { push @for_play, $key - 1; } if (@for_play and not play_videos([@{$videos}[@for_play]])) { __SUB__->($results); } } else { push @for_search, $key; } } if (@for_search) { __SUB__->($yv_obj->search_videos([splice(@for_search)])); } elsif ($opt{auto_next_page}) { @keywords = (':next', grep { $_ !~ /^:(n|next|anp)\z/ } @copy_of_keywords); if (@keywords > 1) { my $timeout = 2; print colored("\n:: Press in $timeout seconds to stop the :anp option.", 'bold green'); eval { local $SIG{ALRM} = sub { die "alarm\n"; }; alarm $timeout; scalar ; alarm 0; }; if ($@) { if ($@ eq "alarm\n") { __SUB__->($results, auto => 1); } else { warn colored("\n[!] Unexpected error: <$@>.", 'bold red') . "\n"; } } else { $opt{auto_next_page} = 0; __SUB__->($results); } } else { warn colored("\n[!] Option ':anp' works only combined with other options!", 'bold red') . "\n"; $opt{auto_next_page} = 0; __SUB__->($results); } } __SUB__->($results) if not $args{auto}; return 1; } sub press_enter_to_continue { say ''; scalar $term->readline(colored("=>> Press ENTER to continue...", 'bold')); } sub main_quit { exit($_[0] // 0); } main_quit(0); =head1 CONFIGURATION OPTIONS =head2 api_host Hostname of an invidious instance. When set to C<"auto">, a random invidious is selected on-demand. List of public invidious instances: https://api.invidious.io/ =head2 auto_captions When set to C<1>, auto-generated captions will be retrieved. By default, auto-generated captions are ignored. =head2 autoplay_mode Enable autoplay mode, which will continuously play related videos. =head2 cache_dir Cache directory where to save temporary files. =head2 colors Use colors for text. =head2 comments_order The sorting order for comments. Valid values: "top", "new". =head2 confirm Display a confirmation message after each video played. =head2 convert_cmd Command to convert videos. Default value: "ffmpeg -i *IN* *OUT*" B<*IN*> gets replaced with the input file. B<*OUT*> gets replaced with the output file. =head2 convert_to Format to convert each downloaded video into. (e.g.: C<"mp3">). =head2 cookie_file Load cookies from a file. Useful to overcome the "429: Too Many Requests" issue. The file must be a C<# Netscape HTTP Cookie File>. Same format as C requires. See also: https://git.conocimientoslibres.ga/software/hypervideo.git/about/#how-do-i-pass-cookies-to-hypervideo =head2 copy_caption When downloading a video, copy the closed-caption (if any) in the same folder with the video. If C and C are both enabled, there is no need to enable this option. =head2 custom_layout_format An array of hash values specifying a custom layout for video results. align # "left" or "right" color # any color name supported by Term::ANSIColor text # the actual text width # width allocated for the text The value for C can be either a number of characters (e.g.: 20) or can be a percentage of the terminal width (e.g.: "15%"). The special tokens for C are listed in: fair-viewer --tricks For better formatting, it's highly recommended to install L or L. =head2 custom_channel_layout_format An array of hash values specifying a custom layout for channel results. =head2 custom_playlist_layout_format An array of hash values specifying a custom layout for playlist results. =head2 dash Include or exclude streams in "Dynamic Adaptive Streaming over HTTP" (DASH) format. =head2 date Search for videos uploaded within a specific amount of time. Valid values: "hour", "today", "week", "month", "year". =head2 debug Enable debug/verbose mode, which will print some extra information. Valid values: 0, 1, 2, 3. =head2 download_and_play Play downloaded videos. =head2 download_with_wget Download videos with C. =head2 downloads_dir Directory where to download files and where to save converted files. =head2 env_proxy Load proxy settings from C<*_proxy> environment variables (if any). =head2 fat32safe When downloading a video, make the filename compatible with the FAT32 filesystem. =head2 ffmpeg_cmd Path to the C program. =head2 fullscreen Play videos in fullscreen mode. =head2 get_captions Download closed-captions for videos (if any). =head2 get_term_width Read the terminal width (`stty size`). =head2 hfr Prefer or ignore High Frame Rate (HFR) video streams. Try to disable this option if the videos are lagging or dropping frames. =head2 highlight_color Highlight color used to highlight watched videos. Any color name supported by L can be used. =head2 highlight_watched Highlight watched videos. =head2 history Enable or disable support for input history. Requires L. =head2 history_file File where to save the input history. =head2 history_limit Maximum number of entries in the history file. When the limit is reached, the oldest half of the history file will be deleted. Set the value to C<-1> for no limit. =head2 http_proxy Set HTTP(S)/SOCKS proxy, using the format: 'proto://domain.tld:port/' If authentication is required, use: 'proto://user:pass@domain.tld:port/' =head2 ignore_av1 Ignore videos in AV1 format. =head2 ignored_projections An array of video projects to ignore. For example, to prefer rectangular projections of 360° videos, use: ignored_projections => ["mesh", "equirectangular"], =head2 interactive Interactive mode, prompting for user-input. =head2 keep_original_video Keep the original video after conversion. When set to C<0>, the original video will be deleted. =head2 maxResults How many results to display per page. Currently, this is not implemented. =head2 merge_into_mkv When downloading split videos, merge the audio+video files into an MKV container. Requires C. =head2 merge_into_mkv_args Arguments for C how to merge the files. =head2 merge_with_captions Include closed-captions inside the MKV container (if any). =head2 order Search order for videos. Valid values: "relevance", "rating", "upload_date", "view_count". =head2 page Page number of results. =head2 prefer_av1 Prefer videos in AV1 format. (experimental) =head2 prefer_mp4 Prefer videos in MP4 (AVC) format. Try to enable this option if the videos are lagging or dropping frames. =head2 prefer_m4a Prefer audio streams in M4A (AAC) format. By default, the OPUS format for audio is preferred. =head2 prefer_invidious Prefer invidious instances over parsing the YouTube website directly. =head2 region ISO 3166 country code (default: "US"). =head2 watch_history Set to C<1> to remember and highlight watched videos across multiple sessions. The video IDs are saved in the filename specified by C. =head2 remove_played_file When C is enabled, remove the file after playing it. =head2 resolution Preferred resolution for videos. Valid values: best, 2160p, 1440p, 1080p, 720p, 480p, 360p, 240p, 144p, audio. =head2 show_video_info Show extra info for videos when selected. =head2 skip_if_exists When downloading, skip if the file already exists locally. =head2 skip_watched Skip already watched/downloaded videos. =head2 split_videos Enable or disable support for split-videos. Split-videos are videos that do not include audio and video in the same file. =head2 srt_languages List of SRT languages in the order of preference. =head2 subscribed_channels_file Absolute path to the file where to store subscribed channels (C<:sub=i>). =head2 subscriptions_limit Maximum number of subscription videos to store in the local database. Set to C<0> for no limit. =head2 thousand_separator Thousands separator character for numbers >= 1000. =head2 timeout HTTPS timeout value in seconds. The default value is 10 seconds. =head2 user_agent Token that is used to identify the user agent on the network. The agent value is sent as the C header in the requests. =head2 video_filename_format The format of filename for downloaded files. The available special tokens are listed in: fair-viewer --tricks =head2 video_player_selected The selected video player defined in the C table. =head2 video_players A table of video players. The keys for each player are: arg # any arguments for the video player audio # option specifying the *AUDIO* file cmd # the main player command fs # the fullscreen option novideo # the no-video mode option srt # option specifying the *SUB* file =head2 videoCaption When set to C<1> or C<"true">, retrieve only videos that contain closed-captions in search results. =head2 videoDefinition When set to C<"high">, retrieve only HD videos in search results. =head2 videoDimension When set to C<"3d">, retrieve only 3D videos in search results. =head2 videoDuration Retrieve only short or long videos in search results. Valid values: "any", "short", "long". =head2 videoLicense When set to C<"creative_commons">, retrieve only videos under the I license in search results. =head2 watch_history Remember watched videos. Watched videos can be listed with: fair-viewer -wv =head2 watched_file File where to save the video IDs of watched/downloaded videos when C is set to a true value. =head2 wget_cmd Command for C when C is set to a true value. =head2 youtube_users_file Absolute path to the file where to store saved channels (C<:save=i>). =head2 youtube_video_url Format for C for constructing an YouTube video URL given the video ID. =head2 ytdl Use C for videos with encrypted signatures. When set to C<0>, invidious instances will be used instead. =head2 ytdl_cmd Command for C when C is set to a true value. =head1 CONFIGURATION FILES The configuration files are: ~/.config/fair-viewer/fair-viewer.conf ~/.config/fair-viewer/gtk-fair-viewer.conf =head1 INVIDIOUS API REFERENCE https://github.com/iv-org/invidious/wiki/API =head1 REPOSITORY https://git.sr.ht/~heckyel/fair-viewer =head1 LICENSE AND COPYRIGHT Copyright 2010-2021 Trizen. This program is free software; you can redistribute it and/or modify it under the terms of either: the GNU General Public License as published by the Free Software Foundation; or the Artistic License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See L for more information. =cut