diff options
author | Jesús <heckyel@hyperbola.info> | 2021-07-09 15:27:16 -0500 |
---|---|---|
committer | Jesús <heckyel@hyperbola.info> | 2021-07-09 15:27:16 -0500 |
commit | 739c821a54c01816e60eb5f774c8977a1e221ea0 (patch) | |
tree | e04a7f5a6fe4d450d43fd45c412f9d415bcb7a7e /bin/fair-viewer | |
parent | c1322a4e9a1fb0a286dab1277a740072d0ab30f9 (diff) | |
download | fair-viewer-739c821a54c01816e60eb5f774c8977a1e221ea0.tar.lz fair-viewer-739c821a54c01816e60eb5f774c8977a1e221ea0.tar.xz fair-viewer-739c821a54c01816e60eb5f774c8977a1e221ea0.zip |
upstream
Diffstat (limited to 'bin/fair-viewer')
-rwxr-xr-x | bin/fair-viewer | 2014 |
1 files changed, 1234 insertions, 780 deletions
diff --git a/bin/fair-viewer b/bin/fair-viewer index e97475c..127f1fe 100755 --- a/bin/fair-viewer +++ b/bin/fair-viewer @@ -1,7 +1,8 @@ #!/usr/bin/perl -# Copyright (C) 2010-2020 Trizen <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d>. -# Copyright (C) 2020 Jesus E. <echo aGVja3llbEBoeXBlcmJvbGEuaW5mbw== | base64 -d>. +# Copyright (C) 2010-2021 Trizen <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d>. +# Copyright (C) 2020-2021 Jesus E. <echo aGVja3llbEBoeXBlcmJvbGEuaW5mbw== | base64 -d>. + # # This program is free software; you can redistribute it and/or modify it # under the terms of either: the GNU General Public License as published @@ -15,9 +16,9 @@ # #------------------------------------------------------- # fair-viewer -# Fork: 14 February 2020 -# Edit: 05 October 2020 -# https://framagit.org/heckyel/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. @@ -31,26 +32,11 @@ fair-viewer - YouTube from command line. -See: fair-viewer --help + fair-viewer --help fair-viewer --tricks fair-viewer --examples fair-viewer --stdin-help -=head1 LICENSE AND COPYRIGHT - -Copyright 2010-2020 Trizen. -Copyright 2020 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 L<https://dev.perl.org/licenses/> for more information. - =cut use utf8; @@ -59,21 +45,22 @@ use 5.016; use warnings; no warnings 'once'; -my $DEVEL; # true in devel mode -use if ($DEVEL = 0), lib => qw(../lib); # devel mode +my $DEVEL; # true in devel mode +use if ($DEVEL = -w __FILE__), lib => qw(../lib); # devel mode use WWW::FairViewer v1.0.6; use WWW::FairViewer::RegularExpressions; +require Storable; + use File::Spec::Functions qw( catdir catfile curdir path rel2abs - tmpdir file_name_is_absolute - ); +); binmode(STDOUT, ':utf8'); @@ -90,7 +77,7 @@ my %opt; my $term_width = 80; # Keep track of watched videos by their ID -my %watched_videos; +my %WATCHED_VIDEOS; # Unchangeable data goes here my %constant = (win32 => ($^O eq 'MSWin32' ? 1 : 0)); # doh @@ -119,18 +106,39 @@ else { $xdg_config_home = catdir($home_dir, '.config'); } -# Configuration dir/file +# Configuration dirs my $config_dir = catdir($xdg_config_home, $execname); -my $config_file = catfile($config_dir, "$execname.conf"); -my $authentication_file = catfile($config_dir, 'reg.dat'); -my $history_file = catfile($config_dir, 'cli-history.txt'); -my $watched_file = catfile($config_dir, 'watched.txt'); -my $api_file = catfile($config_dir, 'api.json'); - -if (not -d $config_dir) { - require File::Path; - File::Path::make_path($config_dir) - or warn "[!] Can't create dir '$config_dir': $!"; +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 { @@ -179,10 +187,10 @@ my %CONFIG = ( : undef # auto-defined ), + split_videos => 1, + # YouTube options - dash_support => 1, - dash_mp4_audio => 1, - dash_segmented => 1, # may load slow + dash => 1, # may load slow maxResults => 20, hfr => 1, # true to prefer high frame rate (HFR) videos resolution => 'best', @@ -192,14 +200,13 @@ my %CONFIG = ( videoCaption => undef, videoDuration => undef, - order => undef, - date => undef, - - comments_order => 'top', # valid values: top, new - subscriptions_order => 'relevance', # valid values: alphabetical, relevance, unread - + 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', @@ -213,59 +220,85 @@ my %CONFIG = ( # API api_host => "auto", - # Others - 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_av1 => 0, - ignore_av1 => 0, - fat32safe => $constant{win32}, - fullscreen => 0, - results_with_details => 0, - results_with_colors => 0, - results_fixed_width => undef, # auto-defined - show_video_info => 1, - interactive => 1, - get_term_width => $constant{win32} ^ 1, - download_with_wget => undef, # auto-defined - thousand_separator => q{,}, - downloads_dir => curdir(), - keep_original_video => 0, - download_and_play => 0, - skip_watched => 0, - remember_watched => 0, - watched_file => $watched_file, - highlight_watched => 1, - highlight_color => 'bold', - remove_played_file => 0, - history => undef, # auto-defined - history_limit => 100_000, - history_file => $history_file, - convert_cmd => 'ffmpeg -i *IN* *OUT*', - convert_to => undef, + # 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 + ytdl_cmd => undef, # auto-defined - custom_layout => 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 => "yellow", text => "*AUTHOR*",}, + {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', @@ -297,13 +330,12 @@ ACTIONS my $control_options = <<'CONTROL'; # Control -:n(ext) : get the next page of results -:b(ack) : get the previous page of results +:n(ext) : display the next page of results +:r(eturn) : return to the previous page of results CONTROL my $other_options = <<'OTHER'; # Others -:r(eturn) : return to previous page of results :refresh : refresh the current list of results :dv=i : display the data structure of result i -argv -argv2=v : apply some arguments (e.g.: -u=google) @@ -332,17 +364,32 @@ Examples: -sp classical music : search for playlists HELP -my $playlists_help = <<"PL_HELP" . $general_help; +my $playlists_help = <<"PLAYLISTS_HELP" . $general_help; -# Playlists +# Select a playlist +<number> : list videos from the selected playlist :pp=i,i : play videos from the selected playlists -PL_HELP +PLAYLISTS_HELP + +my $channels_help = <<"CHANNELS_HELP" . $general_help; -my $comments_help = <<"COM_HELP" . $general_help; +# Select a channel +<number> : 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 -COM_HELP +COMMENTS_HELP my $complete_help = <<"STDIN_HELP"; @@ -356,9 +403,7 @@ $action_options :r(elated)=i : display related videos :u(ploads)=i : display author's latest uploads :pv=i :popular=i : display author's popular uploads -:A(ctivity)=i : display author's recent activity :p(laylists)=i : display author's playlists -:ps=i :s2p=i,i : save videos to a post-selected playlist :subscribe=i : subscribe to author's channel :(dis)like=i : like or dislike a video :fav(orite)=i : favorite a video @@ -450,7 +495,7 @@ sub load_config { $update_config = 1; } - # Locating a video player + # Locate video player if (not $CONFIG{video_player_selected}) { foreach my $key (sort keys %{$CONFIG{video_players}}) { @@ -515,23 +560,6 @@ sub load_config { $update_config = 1; } - # Fixed-width format and custom layout - if ( not defined($CONFIG{results_fixed_width}) - or not defined($CONFIG{custom_layout})) { - - if ( eval { require Unicode::GCString; 1 } - or eval { require Text::CharWidth; 1 }) { - $CONFIG{custom_layout} //= 1; - $CONFIG{results_fixed_width} //= 1; - } - else { - $CONFIG{custom_layout} = 0; - $CONFIG{results_fixed_width} = 0; - } - - $update_config = 1; - } - # Enable history if Term::ReadLine::Gnu::XS is installed if (not defined $CONFIG{history}) { @@ -554,6 +582,7 @@ sub load_config { 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; @@ -566,11 +595,11 @@ sub load_config { load_config($config_file); -if ($opt{remember_watched}) { +if ($opt{watch_history}) { if (-f $opt{watched_file}) { if (open my $fh, '<', $opt{watched_file}) { chomp(my @ids = <$fh>); - @watched_videos{@ids} = (); + @WATCHED_VIDEOS{@ids} = (); close $fh; } else { @@ -601,7 +630,8 @@ if ($opt{history}) { eval { $term->ReadHistory($opt{history_file}) }; # All history entries - my @history = $term->history_list; + 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}) { @@ -620,21 +650,21 @@ if ($opt{history}) { } 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}, - ); + 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},); + thousand_separator => $opt{thousand_separator},); { # Apply the configuration file my %temp = %CONFIG; @@ -672,18 +702,16 @@ usage: $execname [options] ([url] | [keywords]) -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 - -sp --search-pl : search for playlists of videos - --pid=s : list a playlist of videos by playlist ID - --pp=s,s : play the videos from the given playlist IDs - --ps=s : add video by ID or URL to a post-selected playlist - or in a given playlist ID specified with `--pid` - --position=i : position in the playlist where to add the video - -* Activities - -ua --activities:s : show activity events for a given channel + -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 @@ -704,7 +732,7 @@ usage: $execname [options] ([url] | [keywords]) --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 - --date=s : short videos published in a time period + --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) @@ -714,6 +742,8 @@ usage: $execname [options] ([url] | [keywords]) --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. @@ -721,29 +751,27 @@ usage: $execname [options] ([url] | [keywords]) --login : will prompt for authentication (OAuth 2.0) --logout : will delete the authentication key - * [GET] Personal - -U --uploads:s : show the uploads from your channel * - -P --playlists:s : show the playlists from your channel * - -F --favorites:s : show the latest favorited videos * - -S --subscriptions:s : show the subscribed channels * - -SV --subs-videos:s : show the subscription videos (slow) * - --subs-order=s : change the subscription order - valid values: alphabetical, relevance, unread - -L --likes : show the videos that you liked on YouTube * - --dislikes : show the videos that you disliked on YouTube * + * 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 -* [POST] Personal - --subscribe=s : subscribe to a given channel ID or username * - --favorite=s : favorite a video by URL or ID * - --like=s : send a 'like' rating to a video URL or ID * - --dislike=s : send a 'dislike' rating to a video URL or ID * +* 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 --audio! : play audio only, without displaying video + -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}}]} @@ -786,23 +814,21 @@ usage: $execname [options] ([url] | [keywords]) --skip-watched! : don't play already watched videos --highlight! : remember and highlight selected videos --confirm! : show a confirmation message after each play - --prefer-mp4! : prefer videos in MP4 format, instead of WEBM - --prefer-av1! : prefer videos in AV1 format, instead of WEBM + --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 - -C --colorful! : use colors to delimit the video results - -D --details! : display the results with extra details - -W --fixed-width! : adjust the results to fit inside the term width - --custom-layout! : display the results using a custom layout (see conf) -i --info=s : show information for a video ID or URL -e --extract=s : extract information from videos (see: -T) --extract-file=s : extract information from videos in this file @@ -814,17 +840,22 @@ usage: $execname [options] ([url] | [keywords]) --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 - --api=s : set an API host from https://instances.invidio.us/ + --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/' - --dash! : include or exclude the DASH itags - --dash-mp4a! : include or exclude the itags for MP4 audio streams - --dash-segmented! : include or exclude segmented DASH streams + --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) @@ -838,7 +869,6 @@ Help options: --debug:1..3 : see behind the scenes NOTES: - * -> requires authentication ! -> the argument can be negated with '--no-' =i -> requires an integer argument =s -> requires an argument @@ -890,7 +920,6 @@ sub tricks { > ":r", ":return" will return to the previous page of results. For example, if you search for playlists, then select a playlist of videos, inserting ":r" will return back to the playlist results. - Also, for the previous page, you can insert ':b', but ':r' is faster! > "6" (quoted) will search for videos with the keyword '6'. @@ -919,26 +948,32 @@ sub tricks { -> Special tokens: - *ID* : the YouTube video ID - *AUTHOR* : the author name of the video - *CHANNELID* : the channel ID of the video - *RESOLUTION* : the resolution of the video - *VIEWS* : the number of views - *VIEWS_SHORT* : the number of views in abbreviated notation - *LIKES* : the number of likes - *DISLIKES* : the number of dislikes - *RATING* : the rating of the video from 0 to 5 - *COMMENTS* : the number of comments - *DURATION* : the duration of the video in seconds - *PUBLISHED* : the publication date as "DD MMM YYYY" - *AGE* : the age of a video (N days, N months, N years) - *AGE_SHORT* : the abbreviated age of a video (Nd, Nm, Ny) - *DIMENSION* : the dimension of the video (2D or 3D) - *DEFINITION* : the definition of the video (HD or SD) - *TIME* : the duration of the video as "HH::MM::SS" - *TITLE* : the title of the video - *FTITLE* : the title of the video (filename safe) - *DESCRIPTION* : the description of the video + *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 @@ -980,33 +1015,32 @@ sub examples { print <<"EXAMPLES"; ==== COMMAND LINE EXAMPLES ==== -Command: $execname -A -n russian music -category=10 +Command: $execname -A -n russian music Results: play all the video results (-A) only audio, no video (-n) search for "russian music" - in category "10", which is the Music category. - -A will include the videos from the next pages as well. +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 -D -Results: the most recent 5 videos by a specific author (-up), printed with extra details (-D). +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 --page=2 -u=Google -Results: show latest videos uploaded by Google, starting with the page number 2. - Command: $execname cats --order=view_count --duration=short Results: search for 'cats' videos, ordered by ViewCount and short duration. -Command: $execname --channels math lessons +Command: $execname -sc math lessons Results: search for YouTube channels. Command: $execname -uf=Google -Results: show latest videos favorited by a user. +Results: show the latest favorite videos by a user. ==== USER INPUT EXAMPLES ==== @@ -1016,8 +1050,8 @@ A STDIN option can begin with ':', ';' or '='. Command: <ENTER>, :n, :next Results: get the next page of results. -Command: :b, :back (:r, :return) -Results: get the previous page of results. +Command: :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. @@ -1106,13 +1140,13 @@ sub apply_configuration { qw( videoCaption videoDefinition videoDimension videoDuration - videoLicense + videoLicense maxResults api_host date order channelId region debug http_proxy page comments_order - subscriptions_order user_agent + user_agent cookie_file timeout ytdl ytdl_cmd - prefer_mp4 prefer_av1 + prefer_mp4 prefer_av1 prefer_invidious ) ) { @@ -1142,13 +1176,6 @@ sub apply_configuration { }; } - if ($id eq 'mine') { - $id = $yv_obj->my_channel_id() // do { - warn_invalid("username or channel ID", $id); - undef; - }; - } - $yv_obj->set_channelId($id); } else { @@ -1156,10 +1183,6 @@ sub apply_configuration { } } - if (defined $opt->{more_results}) { - $yv_obj->set_maxResults(delete($opt->{more_results}) ? 50 : $CONFIG{maxResults}); - } - if (delete $opt->{authenticate}) { authenticate(); } @@ -1191,7 +1214,15 @@ sub apply_configuration { # ... SUBROUTINE CALLS ... # if (defined $opt->{subscribe}) { - subscribe(split(/[,\s]+/, delete $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}) { @@ -1224,6 +1255,18 @@ sub apply_configuration { 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); @@ -1245,6 +1288,14 @@ sub apply_configuration { 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}; @@ -1259,14 +1310,17 @@ sub apply_configuration { } } else { - print_videos($yv_obj->uploads); + warn_invalid("username or channel ID", $str); } } if (defined $opt->{popular_videos}) { my $str = delete $opt->{popular_videos}; - if (my $id = extract_channel_id($str)) { + 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 { @@ -1275,13 +1329,6 @@ sub apply_configuration { }; } - if ($id eq 'mine') { - $id = $yv_obj->my_channel_id() // do { - warn_invalid("username or channel ID", $id); - undef; - }; - } - print_videos($yv_obj->popular_videos($id)); } else { @@ -1294,42 +1341,6 @@ sub apply_configuration { print_videos($yv_obj->trending_videos_from_category($cat_id)); } - if (defined $opt->{subscriptions}) { - my $str = delete $opt->{subscriptions}; - - if ($str) { - if (my $id = extract_channel_id($str)) { - $yv_utils->is_channelID($id) - ? print_channels($yv_obj->subscriptions($id)) - : print_channels($yv_obj->subscriptions_from_username($id)); - } - else { - warn_invalid("username or channel ID", $str); - } - } - else { - print_channels($yv_obj->subscriptions); - } - } - - if (defined $opt->{subscription_videos}) { - my $str = delete $opt->{subscription_videos}; - - if ($str) { - if (my $id = extract_channel_id($str)) { - $yv_utils->is_channelID($id) - ? print_videos($yv_obj->subscription_videos($id)) - : print_videos($yv_obj->subscription_videos_from_username($id)); - } - else { - warn_invalid("username or channel ID", $str); - } - } - else { - print_videos($yv_obj->subscription_videos); - } - } - if (defined $opt->{related_videos}) { get_and_print_related_videos(split(/[,\s]+/, delete($opt->{related_videos}))); } @@ -1349,7 +1360,7 @@ sub apply_configuration { } } else { - print_playlists($yv_obj->my_playlists); + print_local_playlist(); } } @@ -1367,49 +1378,16 @@ sub apply_configuration { } } else { - print_videos($yv_obj->favorites); - } - } - - if (defined $opt->{likes}) { - my $str = delete($opt->{likes}); - - if ($str) { - if (my $id = extract_channel_id($str)) { - $yv_utils->is_channelID($id) - ? print_videos($yv_obj->likes($id)) - : print_videos($yv_obj->likes_from_username($id)); - } - else { - warn_invalid("username or channel ID", $str); - } - } - else { - print_videos($yv_obj->my_likes); + print_favorite_videos(); } } - if (defined $opt->{dislikes}) { - delete $opt->{dislikes}; - print_videos($yv_obj->my_dislikes); + if (defined delete $opt->{likes}) { + print_liked_videos(); } - if (defined $opt->{activities}) { - my $str = delete $opt->{activities}; - - if ($str) { - if (my $id = extract_channel_id($str)) { - $yv_utils->is_channelID($id) - ? print_videos($yv_obj->activities($id)) - : print_videos($yv_obj->activities_from_username($id)); - } - else { - warn_invalid("username or channel ID", $str); - } - } - else { - print_videos($yv_obj->my_activities); - } + if (defined delete $opt->{dislikes}) { + print_disliked_videos(); } if (defined $opt->{get_comments}) { @@ -1445,11 +1423,16 @@ sub parse_arguments { '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}, @@ -1460,19 +1443,26 @@ sub parse_arguments { '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}, - 'subscriptions|S:s' => \$opt{subscriptions}, - 'subs-videos|SV:s' => \$opt{subscription_videos}, - 'subs-order=s' => \$opt{subscriptions_order}, - 'uploads|U|user|user-videos|uv|u:s' => \$opt{uploads}, + 'uploads|U|user|user-videos|uv|u=s' => \$opt{uploads}, 'favorites|F|user-favorites|uf:s' => \$opt{favorites}, 'playlists|P|user-playlists|up:s' => \$opt{playlists}, - 'activities|activity|ua:s' => \$opt{activities}, - 'likes|L|user-likes|ul:s' => \$opt{likes}, - 'dislikes' => \$opt{dislikes}, + 'likes|L|user-likes' => \$opt{likes}, + 'dislikes|D' => \$opt{dislikes}, 'subscribe=s' => \$opt{subscribe}, 'trending|trends:s' => \$opt{trending}, @@ -1485,7 +1475,7 @@ sub parse_arguments { 'logout' => \$opt{logout}, 'related-videos|rv=s' => \$opt{related_videos}, - 'popular-videos|popular|pv=s' => \$opt{popular_videos}, + 'popular-videos|popular|pv:s' => \$opt{popular_videos}, 'cookie-file|cookies=s' => \$opt{cookie_file}, 'user-agent|agent=s' => \$opt{user_agent}, @@ -1494,7 +1484,7 @@ sub parse_arguments { 'r|region|region-code=s' => \$opt{region}, 'order|order-by|sort|sort-by=s' => \$opt{order}, - 'date=s' => \$opt{date}, + 'time|date=s' => \$opt{date}, 'duration=s' => \$opt{videoDuration}, @@ -1548,43 +1538,42 @@ sub parse_arguments { 'append-arg|append-args=s' => \$MPLAYER{user_defined_arguments}, # Others - 'colorful|colourful|C!' => \$opt{results_with_colors}, - 'details|D!' => \$opt{results_with_details}, - 'fixed-width|W|fw!' => \$opt{results_fixed_width}, - 'captions!' => \$opt{videoCaption}, - 'fullscreen|fs|f!' => \$opt{fullscreen}, - 'dash!' => \$opt{dash_support}, - 'confirm!' => \$opt{confirm}, + '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}, - 'custom-layout!' => \$opt{custom_layout}, - 'custom-layout-format=s' => \$opt{custom_layout_format}, + '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}, - 'dash-m4a|dash-mp4-audio|dash-mp4a!' => \$opt{dash_mp4_audio}, - 'dash-segmented!' => \$opt{dash_segmented}, - 'wget-dl|wget-download!' => \$opt{download_with_wget}, - 'filename|filename-format=s' => \$opt{video_filename_format}, - 'rp|rem-played|remove-played-file!' => \$opt{remove_played_file}, - 'info|i|video-info=s' => \$opt{print_video_info}, - 'get-term-width!' => \$opt{get_term_width}, - 'page=i' => \$opt{page}, - 'novideo|no-video|n|audio!' => \$opt{novideo}, - 'highlight!' => \$opt{highlight_watched}, - 'skip-watched!' => \$opt{skip_watched}, - 'results=i' => \$opt{maxResults}, - 'shuffle|s!' => \$opt{shuffle}, - 'more|m!' => \$opt{more_results}, - 'pos|position=i' => \$opt{position}, - 'ps|playlist-save=s' => \$opt{playlist_save}, + '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}, @@ -1598,6 +1587,7 @@ sub parse_arguments { '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}, @@ -1789,6 +1779,7 @@ sub get_quotewords { sub clear_title { my ($title) = @_; + $title //= ""; $title =~ s/[^\w\s[:punct:]]//g; $title = join(' ', split(' ', $title)); @@ -1909,7 +1900,7 @@ sub get_user_input { sub logout { unlink $authentication_file - or warn "Can't unlink: `$authentication_file' -> $!"; + or warn "[!] Can't unlink: `$authentication_file' -> $!"; $yv_obj->set_access_token(); $yv_obj->set_refresh_token(); @@ -1947,7 +1938,7 @@ INFO if ($remember_me) { $yv_obj->set_authentication_file($authentication_file); $yv_obj->save_authentication_tokens() - or warn "Can't store the authentication tokens: $!"; + or warn "[!] Can't store the authentication tokens: $!"; } else { $yv_obj->set_authentication_file(); @@ -1969,19 +1960,12 @@ sub authenticated { } sub favorite_videos { - my (@videoIDs) = @_; - return if not authenticated(); + my (@videos) = @_; - foreach my $id (@videoIDs) { - my $videoID = get_valid_video_id($id) // next; - - if ($yv_obj->favorite_video($videoID)) { - printf("\n:: Video %s has been successfully favorited.\n", sprintf($CONFIG{youtube_video_url}, $videoID)); - } - else { - warn_cant_do('favorite', $videoID); - } + foreach my $video_data (@videos) { + prepend_video_data_to_file($video_data, $favorite_videos_data_file); } + return 1; } @@ -2006,6 +1990,7 @@ sub save_to_playlist { return if not authenticated(); foreach my $id (@videoIDs) { + my $videoID = get_valid_video_id($id) // next; my $pos = $opt{position}; # position in the playlist @@ -2034,16 +2019,11 @@ sub save_to_playlist { sub rate_videos { my $rating = shift; - return if not authenticated(); - foreach my $id (@_) { - my $videoID = get_valid_video_id($id) // next; - if ($yv_obj->send_rating_to_video($videoID, $rating)) { - printf("\n:: Video %s has been successfully %sd.\n", sprintf($CONFIG{youtube_video_url}, $videoID), $rating); - } - else { - warn_cant_do($rating, $videoID); - } + 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; @@ -2132,33 +2112,6 @@ sub get_and_print_videos_from_playlist { warn_invalid('playlistID', $playlistID); return; } - return 1; -} - -sub subscribe { - my (@ids) = @_; - - return if not authenticated(); - - foreach my $channel (@ids) { - - my $id = extract_channel_id($channel) // do { - warn_invalid("channel ID or username", $channel); - next; - }; - - my $ok = - $yv_utils->is_channelID($id) - ? $yv_obj->subscribe_channel($id) - : $yv_obj->subscribe_channel_from_username($id); - - if ($ok) { - print ":: Successfully subscribed to channel: $id\n"; - } - else { - warn colored("\n[!] Unable to subscribe to channel: $id", 'bold red') . "\n"; - } - } return 1; } @@ -2220,7 +2173,7 @@ sub general_options { if ($option =~ /^(?:q|quit|exit)\z/) { main_quit(0); } - elsif ($option =~ /^(?:n|next)\z/ and defined $url) { + 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); @@ -2235,11 +2188,7 @@ sub general_options { $callback->($request); } } - elsif ($option =~ /^(?:b|back|p|prev|previous)\z/ and defined $url) { - my $request = $yv_obj->previous_page($url); - $callback->($request); - } - elsif ($option =~ /^(?:R|refresh)\z/ and defined $url) { + elsif ($option =~ /^(?:R|refresh)\z/ and defined($url)) { @{$results} = @{$yv_obj->_get_results($url)->{results}}; } elsif ($option eq 'login') { @@ -2312,7 +2261,7 @@ sub warn_last_page { } sub warn_first_page { - warn colored("\n[!] No previous page available...", 'bold red') . "\n"; + warn colored("\n[!] This is the first page!", 'bold red') . "\n"; } sub warn_no_thing_selected { @@ -2408,52 +2357,109 @@ sub adjust_width { } ); - # - ## Unicode::GCString - # - if ($pkg eq 'Unicode::GCString') { + my $adjust_str = sub { + + # Unicode::GCString + if ($pkg eq 'Unicode::GCString') { - my $gcstr = Unicode::GCString->new($str); - my $str_width = $gcstr->columns; + my $gcstr = Unicode::GCString->new($str); + my $str_width = $gcstr->columns; - if ($str_width != $len) { while ($str_width > $len) { $gcstr = $gcstr->substr(0, -1); $str_width = $gcstr->columns; } $str = $gcstr->as_string; - my $spaces = ' ' x ($len - $str_width); - $str = $prepend ? "$spaces$str" : "$str$spaces"; + return ($str, $str_width); } - return $str; - } - - # - ## Text::CharWidth - # - if ($pkg eq 'Text::CharWidth') { + # Text::CharWidth + if ($pkg eq 'Text::CharWidth') { - my $str_width = Text::CharWidth::mbswidth($str); + my $str_width = Text::CharWidth::mbswidth($str); - if ($str_width != $len) { while ($str_width > $len) { chop $str; $str_width = Text::CharWidth::mbswidth($str); } - my $spaces = ' ' x ($len - $str_width); - $str = $prepend ? "$spaces$str" : "$str$spaces"; + return ($str, $str_width); } - return $str; + # 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"; } - return $str; + 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) = @_; @@ -2461,66 +2467,38 @@ sub print_channels { warn_no_results("channel"); } - if ($opt{get_term_width} and $opt{results_fixed_width}) { + if ($opt{get_term_width}) { get_term_width(); } my $url = $results->{url}; my $channels = $results->{results} // []; - foreach my $i (0 .. $#{$channels}) { - my $channel = $channels->[$i]; + 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 = []; + } + } - if ($opt{results_with_details}) { - printf( - "\n%s. %s\n %s: %-23s %s: %-12s\n%s\n", - colored(sprintf('%2d', $i + 1), 'bold') => colored($yv_utils->get_channel_title($channel), 'bold blue'), - colored('Updated' => 'bold') => $yv_utils->get_publication_date($channel), - colored('Author' => 'bold') => $yv_utils->get_channel_title($channel), - wrap_text( - i_tab => q{ } x 4, - s_tab => q{ } x 4, - text => [$yv_utils->get_description($channel) || 'No description available...'] - ), - ); - } - elsif ($opt{results_with_colors}) { - print "\n" if $i == 0; - printf("%s. %s (%s)\n", - colored(sprintf('%2d', $i + 1), 'bold'), - colored($yv_utils->get_channel_title($channel), 'blue'), - colored($yv_utils->get_publication_date($channel), 'magenta'), - ); - } - elsif ($opt{results_fixed_width}) { - - require List::Util; - - my @authors = map { $yv_utils->get_channel_id($_) } @{$channels}; - my @dates = map { $yv_utils->get_publication_date($_) } @{$channels}; - - my $author_width = List::Util::min(List::Util::max(map { length($_) } @authors), int($term_width / 5)); - my $dates_width = List::Util::max(map { length($_) } @dates); - my $title_length = $term_width - ($author_width + $dates_width + 2 + 3 + 1 + 2); + my @formatted; - print "\n"; - foreach my $i (0 .. $#{$channels}) { + foreach my $i (0 .. $#{$channels}) { - my $channel = $channels->[$i]; - my $title = clear_title($yv_utils->get_channel_title($channel)); + my $channel = $channels->[$i]; + my $entry = $opt{custom_channel_layout_format}; - printf "%s. %s %s [%*s]\n", colored(sprintf('%2d', $i + 1), 'bold'), - adjust_width($title, $title_length), - adjust_width($authors[$i], $author_width, 1), - $dates_width, $dates[$i]; - } - last; - } - else { - print "\n" if $i == 0; - printf "%s. %s [%s]\n", colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_channel_title($channel), - $yv_utils->get_publication_date($channel); - } + push @formatted, format_line_result($i, $entry, $channel); + } + + if (@formatted) { + print "\n" . join("", @formatted); } my @keywords = get_input_for_channels(); @@ -2542,13 +2520,122 @@ sub print_channels { ) { ## ok } + + # :h, :help elsif ($opt =~ /^(?:h|help)\z/) { - print $general_help; + 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); } @@ -2688,6 +2775,363 @@ sub print_comments { __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) = @_; @@ -2753,7 +3197,7 @@ sub print_playlists { warn_no_results("playlist"); } - if ($opt{get_term_width} and $opt{results_fixed_width}) { + if ($opt{get_term_width}) { get_term_width(); } @@ -2764,77 +3208,27 @@ sub print_playlists { if (exists $playlists->{playlists}) { $playlists = $playlists->{playlists}; } + elsif (exists $playlists->{entries}) { + $playlists = $playlists->{entries}; + } else { warn "\n[!] No playlists...\n"; $playlists = []; } } - state $info_format = <<"FORMAT"; - -TITLE: %s - ID: %s - URL: https://www.youtube.com/playlist?list=%s -DESCR: %s -FORMAT + my @formatted; foreach my $i (0 .. $#{$playlists}) { - my $playlist = $playlists->[$i]; - if ($opt{results_with_details}) { - printf( - "\n%s. %s\n %s: %-25s %s: %s\n%s\n", - colored(sprintf('%2d', $i + 1), 'bold') => colored($yv_utils->get_title($playlist), 'bold blue'), - colored('Updated' => 'bold') => $yv_utils->get_publication_date($playlist), - colored('Author' => 'bold') => $yv_utils->get_channel_title($playlist), - wrap_text( - i_tab => q{ } x 4, - s_tab => q{ } x 4, - text => [$yv_utils->get_description($playlist) || 'No description available...'] - ), - ); - } - elsif ($opt{results_with_colors}) { - print "\n" if $i == 0; - printf( - "%s. %s (%s) %s\n", - colored(sprintf('%2d', $i + 1), 'bold'), - colored($yv_utils->get_title($playlist), 'blue'), - colored($yv_utils->get_publication_date($playlist), 'magenta'), - colored($yv_utils->get_channel_title($playlist), 'green'), - ); - } - elsif ($opt{results_fixed_width}) { - - require List::Util; - - my @authors = map { $yv_utils->get_channel_title($_) } @{$playlists}; - my @dates = map { $yv_utils->get_publication_date($_) } @{$playlists}; - - my $author_width = List::Util::min(List::Util::max(map { length($_) } @authors), int($term_width / 5)); - my $dates_width = List::Util::max(map { length($_) } @dates); - my $title_length = $term_width - ($author_width + $dates_width + 2 + 3 + 1 + 2); - print "\n"; - foreach my $i (0 .. $#{$playlists}) { + my $playlist = $playlists->[$i]; + my $entry = $opt{custom_playlist_layout_format}; - my $playlist = $playlists->[$i]; - my $title = clear_title($yv_utils->get_title($playlist)); + push @formatted, format_line_result($i, $entry, $playlist); + } - printf "%s. %s %s [%*s]\n", colored(sprintf('%2d', $i + 1), 'bold'), - adjust_width($title, $title_length), - adjust_width($authors[$i], $author_width, 1), - $dates_width, $dates[$i]; - } - last; - } - else { - print "\n" if $i == 0; - printf( - "%s. %s (by %s) [%s]\n", - colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_title($playlist), - $yv_utils->get_channel_title($playlist), $yv_utils->get_publication_date($playlist) - ); - } + if (@formatted) { + print "\n" . join("", @formatted); } state @keywords; @@ -2873,17 +3267,12 @@ FORMAT elsif ($opt =~ /^(?:r|return)\z/) { return; } - elsif ($opt =~ /^i(?:nfo)?${digit_or_equal_re}(.*)/) { + + # :i=i, :info=i + elsif ($opt =~ /^(?:i|info)${digit_or_equal_re}(.*)/) { if (my @ids = get_valid_numbers($#{$playlists}, $1)) { foreach my $id (@ids) { - my $desc = wrap_text( - i_tab => q{ } x 7, - s_tab => q{ } x 7, - text => [$yv_utils->get_description($playlists->[$id]) || 'No description available...'] - ); - $desc =~ s/^\s+//; - printf $info_format, $yv_utils->get_title($playlists->[$id]), - ($yv_utils->get_playlist_id($playlists->[$id])) x 2, $desc; + print_playlist_info($playlists->[$id]); } press_enter_to_continue(); } @@ -2891,6 +3280,8 @@ FORMAT 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]); @@ -2915,7 +3306,12 @@ FORMAT return $id; } - get_and_print_videos_from_playlist($id); + if ($id =~ m{^/}) { # local playlist + print_local_playlist($id); + } + else { + get_and_print_videos_from_playlist($id); + } } else { push @for_search, $key; @@ -2980,28 +3376,35 @@ sub get_streaming_url { 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 => $CONFIG{srt_languages}, - yv_obj => $yv_obj, - ); + 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 DASH itags - my $dash = 1; + # Include split-videos + my $split_videos = 1; - # Exclude DASH itags in download-mode or when no video output is required - if ($opt{novideo} or not $opt{dash_support}) { - $dash = 0; + # 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}) { - $dash = $opt{merge_into_mkv} ? 1 : 0; + $split_videos = $opt{merge_into_mkv} ? 1 : 0; } my ($streaming, $resolution) = $yv_itags->find_streaming_url( @@ -3011,9 +3414,11 @@ sub get_streaming_url { hfr => $opt{hfr}, ignore_av1 => $opt{ignore_av1}, - dash => $dash, - dash_mp4_audio => ($opt{novideo} ? 1 : $opt{dash_mp4_audio}), - dash_segmented => ($opt{download_video} ? 0 : $opt{dash_segmented}), + split => $split_videos, + prefer_m4a => $opt{prefer_m4a}, + dash => ($opt{download_video} ? 0 : $opt{dash}), + + ignored_projections => $opt{ignored_projections}, ); return { @@ -3042,7 +3447,12 @@ sub download_from_url { if (defined($lwp_dl)) { my @cmd = ($lwp_dl, $url, "$output_filename.part"); $yv_obj->proxy_system(@cmd); - return if $?; + 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; } @@ -3083,19 +3493,12 @@ sub download_from_url { sub download_video { my ($streaming, $info) = @_; - my $fat32safe = $opt{fat32safe}; - state $unix_like = $^O =~ /^(?:linux|freebsd|openbsd)\z/i; - - if (not $fat32safe and not $unix_like) { - $fat32safe = 1; - } - my $video_filename = $yv_utils->format_text( streaming => $streaming, info => $info, text => $opt{video_filename_format}, escape => 0, - fat32safe => $fat32safe, + fat32safe => $opt{fat32safe}, ); my $naked_filename = $video_filename =~ s/\.\w+\z//r; @@ -3199,6 +3602,7 @@ sub download_video { # 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}; @@ -3263,17 +3667,45 @@ sub download_video { 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) = @_; + my ($video_id, $video_data) = @_; + + if ($opt{watch_history}) { - if ($opt{remember_watched} and not exists($watched_videos{$video_id})) { - $watched_videos{$video_id} = 1; - open my $fh, '>>', $opt{watched_file} or return; - say {$fh} $video_id; - close $fh; + 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; + $WATCHED_VIDEOS{$video_id} = 1; return 1; } @@ -3309,7 +3741,7 @@ sub get_player_command { ) ); - my $has_video = $cmd =~ /\*(?:VIDEO|URL|ID)\*/; + my $has_video = $cmd =~ /(?:^|\s)\*(?:VIDEO|URL)\*(?:\s|\z)/; $cmd = $yv_utils->format_text( streaming => $streaming, @@ -3355,7 +3787,7 @@ sub play_videos { } # Ignore already watched videos - if (exists($watched_videos{$video_id}) and $opt{skip_watched}) { + if (exists($WATCHED_VIDEOS{$video_id}) and $opt{skip_watched}) { say ":: Already watched video (ID: $video_id)... Skipping..."; next; } @@ -3440,7 +3872,7 @@ sub play_videos { } } - save_watched_video($video_id); + save_watched_video($video_id, $video); press_enter_to_continue() if $opt{confirm}; } @@ -3476,11 +3908,113 @@ sub play_videos_matched_by_regex { 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( @@ -3514,6 +4048,7 @@ sub print_video_info { ['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))], @@ -3535,7 +4070,7 @@ sub print_videos { warn_no_results("video"); } - if ($opt{get_term_width} and $opt{results_fixed_width}) { + if ($opt{get_term_width}) { get_term_width(); } @@ -3546,13 +4081,20 @@ sub print_videos { $videos = $videos->{videos}; } - if (ref($videos) ne 'ARRAY') { + if (ref($videos) eq 'HASH' and exists $videos->{entries}) { + $videos = $videos->{entries}; + } - my $current_instance = $yv_obj->get_api_host(); + my $token = undef; - say "\n:: Probably $current_instance is down. Try:"; + 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://libregit.org/heckyel/fair-viewer#invidious-instances"; + say "See also: https://git.sr.ht/~heckyel/fair-viewer#invidious-instances"; return; } @@ -3619,130 +4161,17 @@ sub print_videos { my @formatted; foreach my $i (0 .. $#{$videos}) { - my $video = $videos->[$i]; - - #use Data::Dump qw(pp); - #pp $video; - if ($opt{custom_layout}) { - - my $entry = $opt{custom_layout_format}; - - if (ref($entry) eq '') { - $entry =~ s/\*NO\*/sprintf('%2d', $i+1)/ge; - $entry = $yv_utils->format_text( - info => $video, - text => $entry, - escape => 0, - ); - push @formatted, "$entry\n"; - } - - if (ref($entry) eq 'ARRAY') { - - my @columns; - - foreach my $slot (@$entry) { - - my $text = $slot->{text}; - my $width = $slot->{width} // 10; - my $color = $slot->{color}; - my $align = $slot->{align} // 'left'; - - if ($width =~ /^(\d+)%\z/) { - $width = int(($term_width * $1) / 100); - } - - $text =~ s/\*NO\*/$i+1/ge; - - $text = $yv_utils->format_text( - info => $video, - text => $text, - escape => 0, - ); - - $text = clear_title($text); - $text = adjust_width($text, $width, ($align eq 'right')); - - if (defined($color)) { - $text = colored($text, $color); - } - - push @columns, $text; - } + my $video = $videos->[$i]; + my $entry = $opt{custom_layout_format}; - push @formatted, join(' ', @columns) . "\n"; - } - } - elsif ($opt{results_with_details}) { - push @formatted, - ($i == 0 ? '' : "\n") - . sprintf( - "%s. %s\n" . " %s: %-16s %s: %-13s %s: %s\n" . " %s: %-12s %s: %-10s %s: %s\n%s\n", - colored(sprintf('%2d', $i + 1), 'bold') => colored($yv_utils->get_title($video), 'bold blue'), - colored('Views' => 'bold') => $yv_utils->set_thousands($yv_utils->get_views($video)), - colored('Likes' => 'bold') => $yv_utils->set_thousands($yv_utils->get_likes($video)), - colored('Dislikes' => 'bold') => $yv_utils->set_thousands($yv_utils->get_dislikes($video)), - colored('Published' => 'bold') => $yv_utils->get_publication_date($video), - colored('Duration' => 'bold') => $yv_utils->get_time($video), - colored('Author' => 'bold') => $yv_utils->get_channel_title($video), - wrap_text( - i_tab => q{ } x 4, - s_tab => q{ } x 4, - text => [$yv_utils->get_description($video) || 'No description available...'] - ), - ); - } - elsif ($opt{results_with_colors}) { - my $definition = $yv_utils->get_definition($video); - push @formatted, - sprintf( - "%s. %s (%s) %s\n", - colored(sprintf('%2d', $i + 1), 'bold'), - colored($yv_utils->get_title($video), 'blue'), - colored($yv_utils->get_time($video), 'magenta'), - colored($yv_utils->get_channel_title($video), 'green'), - ); - } - elsif ($opt{results_fixed_width}) { - - require List::Util; - - my @durations = map { $yv_utils->get_duration($_) } @{$videos}; - my @authors = map { $yv_utils->get_channel_title($_) } @{$videos}; - - my $author_width = List::Util::min(List::Util::max(map { length($_) } @authors) || 1, int($term_width / 5)); - my $time_width = List::Util::first(sub { $_ >= 3600 }, @durations) ? 8 : 6; - my $title_length = $term_width - ($author_width + $time_width + 3 + 2 + 1); - - foreach my $i (0 .. $#{$videos}) { - - my $video = $videos->[$i]; - my $title = clear_title($yv_utils->get_title($video)); - - push @formatted, - sprintf("%s. %s %s %*s\n", - colored(sprintf('%2d', $i + 1), 'bold'), - adjust_width($title, $title_length), - adjust_width($yv_utils->get_channel_title($video), $author_width, 1), - $time_width, $yv_utils->get_time($video)); - } - last; - } - else { - push @formatted, - sprintf( - "%s. %s (by %s) [%s]\n", - colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_title($video), - $yv_utils->get_channel_title($video), $yv_utils->get_time($video), - ); - } + 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)})) { + if (exists($WATCHED_VIDEOS{$yv_utils->get_video_id($video)})) { $formatted[$i] = colored(colorstrip($formatted[$i]), $opt{highlight_color}); } } @@ -3763,7 +4192,7 @@ sub print_videos { ) { if ($opt{play_backwards}) { if (defined($url)) { - __SUB__->($yv_obj->previous_page($url), auto => 1); + return; } else { $opt{play_backwards} = 0; @@ -3772,8 +4201,8 @@ sub print_videos { } } else { - if (defined($url)) { - __SUB__->($yv_obj->next_page($url), auto => 1); + if (defined($url) or ref($token) eq 'CODE') { + __SUB__->($yv_obj->next_page($url, $token), auto => 1); } else { $opt{play_all} = 0; @@ -3827,8 +4256,8 @@ sub print_videos { press_enter_to_continue(); } elsif ($opt =~ /^(?:n|next)\z/) { - if (defined($url)) { - my $request = $yv_obj->next_page($url); + if (defined($url) or ref($token) eq 'CODE') { + my $request = $yv_obj->next_page($url, $token); __SUB__->($request, @keywords ? (auto => 1) : ()); } else { @@ -3840,21 +4269,19 @@ sub print_videos { } } } - elsif ($opt =~ /^(?:b|back|p|prev|previous)\z/) { - if (defined($url)) { - __SUB__->($yv_obj->previous_page($url), @keywords ? (auto => 1) : ()); - } - else { - warn_first_page(); - } - } + + # :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) { @@ -3872,49 +4299,56 @@ sub print_videos { warn_no_thing_selected('video'); } } - elsif ($opt =~ /^(?:pv|popular)${digit_or_equal_re}(.*)/) { + + # :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 $request = $yv_obj->popular_videos($channel_id); - if ($yv_utils->has_entries($request)) { - __SUB__->($request); - } - else { - warn_no_results('popular video'); - } + 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'); } } - elsif ($opt =~ /^(?:A|[Aa]ctivity)${digit_or_equal_re}(.*)/) { + + # :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->activities($channel_id); + my $request = $yv_obj->popular_videos($channel_id); if ($yv_utils->has_entries($request)) { __SUB__->($request); } else { - warn_no_results('activity'); + warn_no_results('popular video'); } } } else { - warn_no_thing_selected('activity'); - } - } - elsif ($opt =~ /^(?:ps|s2p)${digit_or_equal_re}(.*)/) { - if (my @nums = get_valid_numbers($#{$videos}, $1)) { - select_and_save_to_playlist(map { $yv_utils->get_video_id($videos->[$_]) } @nums); - } - else { warn_no_thing_selected('video'); } } - elsif ($opt =~ /^(?:p|playlists?|up)${digit_or_equal_re}(.*)/) { + + # :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])); @@ -3930,10 +4364,12 @@ sub print_videos { 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 { $yv_utils->get_video_id($videos->[$_]) } @nums); + rate_videos($rating, map { $videos->[$_] } @nums); } else { warn_no_thing_selected('video'); @@ -3941,15 +4377,7 @@ sub print_videos { } elsif ($opt =~ /^(?:fav|favorite|F)${digit_or_equal_re}(.*)/) { if (my @nums = get_valid_numbers($#{$videos}, $1)) { - favorite_videos(map { $yv_utils->get_video_id($videos->[$_]) } @nums); - } - else { - warn_no_thing_selected('video'); - } - } - elsif ($opt =~ /^(?:subscribe|S)${digit_or_equal_re}(.*)/) { - if (my @nums = get_valid_numbers($#{$videos}, $1)) { - subscribe(map { $yv_utils->get_channel_id($videos->[$_]) } @nums); + favorite_videos(map { $videos->[$_] } @nums); } else { warn_no_thing_selected('video'); @@ -4150,7 +4578,11 @@ main_quit(0); =head2 api_host -Hostname of an invidious instance. When set to C<"auto">, a random invidious is selected everytime when the program is started. +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 @@ -4200,7 +4632,7 @@ The file must be a C<# Netscape HTTP Cookie File>. Same format as C<hypervideo> See also: - https://libregit.org/heckyel/hypervideo#how-do-i-pass-cookies-to-hypervideo + https://git.conocimientoslibres.ga/software/hypervideo.git/about/#how-do-i-pass-cookies-to-hypervideo =head2 copy_caption @@ -4208,18 +4640,12 @@ When downloading a video, copy the closed-caption (if any) in the same folder wi If C<merge_into_mkv> and C<merge_with_captions> are both enabled, there is no need to enable this option. -=head2 custom_layout - -Use a custom layout for video results, defined in C<custom_layout_format>. - -Requires: L<Unicode::GCString> or L<Text::CharWidth>. - =head2 custom_layout_format An array of hash values specifying a custom layout for video results. align # "left" or "right" - color # any color supported by Term::ANSIColor + color # any color name supported by Term::ANSIColor text # the actual text width # width allocated for the text @@ -4229,17 +4655,19 @@ The special tokens for C<text> are listed in: fair-viewer --tricks -=head2 dash_mp4_audio +For better formatting, it's highly recommended to install L<Unicode::GCString> or L<Text::CharWidth>. -Include or exclude MP4/M4A (AAC) audio files. +=head2 custom_channel_layout_format -=head2 dash_segmented +An array of hash values specifying a custom layout for channel results. -Include or exclude streams in "Dynamic Adaptive Streaming over HTTP" (DASH) format. +=head2 custom_playlist_layout_format -=head2 dash_support +An array of hash values specifying a custom layout for playlist results. -Enable or disable support for split videos. +=head2 dash + +Include or exclude streams in "Dynamic Adaptive Streaming over HTTP" (DASH) format. =head2 date @@ -4291,13 +4719,15 @@ Read the terminal width (`stty size`). =head2 hfr -Include or exclude High Frame Rate (HFR) videos. +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 supported by L<Term::ANSIColor> can be used. +Any color name supported by L<Term::ANSIColor> can be used. =head2 highlight_watched @@ -4317,7 +4747,7 @@ File where to save the input history. Maximum number of entries in the history file. -When the limit is reached, the first half of the history file will be deleted. +When the limit is reached, the oldest half of the history file will be deleted. Set the value to C<-1> for no limit. @@ -4325,16 +4755,24 @@ Set the value to C<-1> for no limit. Set HTTP(S)/SOCKS proxy, using the format: - proto://domain.tld:port/ + 'proto://domain.tld:port/' If authentication is required, use: - proto://user:pass@domain.tld:port/ + '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. @@ -4351,7 +4789,7 @@ Currently, this is not implemented. =head2 merge_into_mkv -During download, merge the audio+video files into an MKV container. +When downloading split videos, merge the audio+video files into an MKV container. Requires C<ffmpeg>. @@ -4375,17 +4813,29 @@ Page number of results. =head2 prefer_av1 -Prefer videos in AV1 format. (just for testing) +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 remember_watched +=head2 watch_history Set to C<1> to remember and highlight watched videos across multiple sessions. @@ -4401,20 +4851,6 @@ Preferred resolution for videos. Valid values: best, 2160p, 1440p, 1080p, 720p, 480p, 360p, 240p, 144p, audio. -=head2 results_fixed_width - -Results in fixed-width format. - -Requires: L<Unicode::GCString> or L<Text::CharWidth>. - -=head2 results_with_colors - -Results with colors. - -=head2 results_with_details - -Results with extra details. - =head2 show_video_info Show extra info for videos when selected. @@ -4427,13 +4863,21 @@ When downloading, skip if the file already exists locally. 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 subscriptions_order +=head2 subscribed_channels_file + +Absolute path to the file where to store subscribed channels (C<:sub=i>). + +=head2 subscriptions_limit -Order of subscriptions. Currently, not implemented. +Maximum number of subscription videos to store in the local database. Set to C<0> for no limit. =head2 thousand_separator @@ -4457,7 +4901,7 @@ The available special tokens are listed in: =head2 video_player_selected -The selected video player defined the C<video_players> table. +The selected video player defined in the C<video_players> table. =head2 video_players @@ -4474,7 +4918,7 @@ The keys for each player are: =head2 videoCaption -When set to C<1> or C<"true">, retrieve only the videos that contain closed-captions in search results. +When set to C<1> or C<"true">, retrieve only videos that contain closed-captions in search results. =head2 videoDefinition @@ -4494,14 +4938,24 @@ Valid values: "any", "short", "long". When set to C<"creative_commons">, retrieve only videos under the I<Creative Commons> 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<remember_watched> is set to a true value. +File where to save the video IDs of watched/downloaded videos when C<watch_history> is set to a true value. =head2 wget_cmd Command for C<wget> when C<download_with_wget> 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<sprintf()> for constructing an YouTube video URL given the video ID. @@ -4529,11 +4983,11 @@ https://github.com/iv-org/invidious/wiki/API =head1 REPOSITORY -https://libregit.org/heckyel/fair-viewer +https://git.sr.ht/~heckyel/fair-viewer =head1 LICENSE AND COPYRIGHT -Copyright 2010-2020 Trizen. +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 |