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 | |
parent | c1322a4e9a1fb0a286dab1277a740072d0ab30f9 (diff) | |
download | fair-viewer-739c821a54c01816e60eb5f774c8977a1e221ea0.tar.lz fair-viewer-739c821a54c01816e60eb5f774c8977a1e221ea0.tar.xz fair-viewer-739c821a54c01816e60eb5f774c8977a1e221ea0.zip |
upstream
Diffstat (limited to 'bin')
-rwxr-xr-x | bin/fair-viewer | 2014 | ||||
-rwxr-xr-x | bin/gtk-fair-viewer | 1509 |
2 files changed, 2230 insertions, 1293 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 diff --git a/bin/gtk-fair-viewer b/bin/gtk-fair-viewer index 62a6d18..3cba73a 100755 --- a/bin/gtk-fair-viewer +++ b/bin/gtk-fair-viewer @@ -1,7 +1,7 @@ #!/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 +15,9 @@ # #------------------------------------------------------- # GTK Fair Viewer -# Fork: 14 February 2020 -# Edit: 30 October 2020 -# https://framagit.org/heckyel/fair-viewer +# Fork: 30 October 2020 +# Edit: 19 June 2021 +# https://git.sr.ht/~heckyel/fair-viewer #------------------------------------------------------- # This is a fork of youtube-viewer: @@ -29,8 +29,8 @@ use 5.016; use warnings; no warnings 'once'; -my $DEVEL; # true in devel mode -use if ($DEVEL = 0), lib => qw(../lib); # devel only +my $DEVEL; # true in devel mode +use if ($DEVEL = -w __FILE__), lib => qw(../lib); # devel only use WWW::FairViewer v1.0.6; use WWW::FairViewer::RegularExpressions; @@ -45,13 +45,20 @@ use File::Spec::Functions qw( path tmpdir file_name_is_absolute - ); +); +require Storable; binmode(STDOUT, ':utf8'); my $appname = 'GTK+ Fair Viewer'; my $version = $WWW::FairViewer::VERSION; +# Saved and subscribed channels +my %channels; +my %subscribed_channels; +my %removed_channels; +my %unsubbed_channels; + # Share directory my $share_dir = ($DEVEL and -d "../share") @@ -83,27 +90,47 @@ else { $xdg_config_home = catdir($home_dir, '.config'); } -# Configuration dir/file +# Configuration dirs my $config_dir = catdir($xdg_config_home, 'fair-viewer'); -my $config_file = catfile($config_dir, "gtk-fair-viewer.conf"); -my $youtube_users_file = catfile($config_dir, 'users.txt'); -my $history_file = catfile($config_dir, 'gtk-history.txt'); -my $session_file = catfile($config_dir, 'session.dat'); -my $authentication_file = catfile($config_dir, 'reg.dat'); -my $api_file = catfile($config_dir, 'api.json'); +my $local_playlists_dir = catdir($config_dir, 'playlists'); + +# Config files +my $config_file = catfile($config_dir, "gtk-fair-viewer.conf"); +my $youtube_users_file = catfile($config_dir, 'users.txt'); +my $subscribed_channels_file = catfile($config_dir, 'subscribed_channels.txt'); +my $history_file = catfile($config_dir, 'gtk-history.txt'); +my $session_file = catfile($config_dir, 'session.dat'); +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 configuration directory -foreach my $dir ($config_dir) { +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 the configuration directory `$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); } } # Video queue for the enqueue feature my @VIDEO_QUEUE; +# Keep track of watched videos +my %WATCHED_VIDEOS; + sub which_command { my ($cmd) = @_; @@ -121,19 +148,21 @@ sub which_command { } my %symbols = ( - up_arrow => '↑', - down_arrow => '↓', - diamond => '❖', - face => '☺', - black_face => '☻', - average => 'x̄', - ellipsis => '…', - play => '▶', - views => '◈', - heart => '❤', - right_arrow => '→', - crazy_arrow => '↬', - numero => '№', + thumbs_up => '👍', + thumbs_down => '👎', + type => '💡', + author => '😃', + author_id => '🤖', + average => '📊', + category => '🗃️', + play => '▶️', + views => '👀', + heart => '❤️', + published => '⏱️', + updated => '✨', + numero => '#️⃣', + video => '🎞️', + subs => '👪', ); # Main configuration @@ -162,7 +191,7 @@ my %CONFIG = ( arg => q{--really-quiet --force-media-title=*TITLE* --no-ytdl *VIDEO*}, }, }, - video_player_selected => 'mpv', # autodetect it later + video_player_selected => undef, # autodetect it later # GUI options clear_text_entries_on_click => 0, @@ -176,22 +205,23 @@ my %CONFIG = ( hpaned_width => 250, hpaned_position => 420, - # Fair options - dash_support => 1, - dash_mp4_audio => 1, - dash_segmented => 1, # may load slow - prefer_mp4 => 0, - prefer_av1 => 0, - ignore_av1 => 0, - maxResults => 10, - hfr => 1, # true to prefer high frame rate (HFR) videos - resolution => 'best', - videoDimension => undef, - videoLicense => undef, - region => undef, - - comments_width => 80, # wrap comments longer than `n` characters - comments_order => 'top', # valid values: time, relevance + # Pipe options + split_videos => 1, + dash => 1, # may load slow + prefer_mp4 => 0, + prefer_av1 => 0, + ignore_av1 => 0, + prefer_m4a => 0, + prefer_invidious => 0, + maxResults => 10, + hfr => 1, # true to prefer high frame rate (HFR) videos + resolution => 'best', + videoDimension => undef, + videoLicense => undef, + region => undef, + + comments_width => 80, # wrap comments longer than `n` characters + comments_order => 'top', # valid values: time, relevance # API api_host => "auto", @@ -206,7 +236,7 @@ my %CONFIG = ( srt_languages => ['en', 'es'], get_captions => 1, auto_captions => 0, - cache_dir => undef, + cache_dir => undef, # will be defined later # Others env_proxy => 1, @@ -228,22 +258,36 @@ my %CONFIG = ( tooltips => 1, tooltip_max_len => 512, # max length of description in tooltips - thousand_separator => q{,}, - downloads_dir => curdir(), - web_browser => undef, # defaults to $ENV{WEBBROWSER} or xdg-open - terminal => undef, # autodetect it later - terminal_exec => q{-e '%s'}, - fair_viewer => undef, - fair_viewer_args => [], - youtube_users_file => $youtube_users_file, + thousand_separator => q{,}, + downloads_dir => curdir(), + web_browser => undef, # defaults to $ENV{WEBBROWSER} or xdg-open + terminal => undef, # autodetect it later + terminal_exec => q{-e '%s'}, + pipe_viewer => undef, + pipe_viewer_args => [], + + ignored_projections => [], + + # 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, + history => 1, history_limit => 100_000, history_file => $history_file, recent_history => 10, remember_session => 1, remember_session_depth => 10, - save_titles_to_history => 0, entry_completion_limit => 10, + + # Save titles + save_titles_to_history => 0, + save_watched_to_history => 0, ); { @@ -353,7 +397,6 @@ my %objects = ( 'clear_list_checkbutton' => \my $clear_list_checkbutton, 'dash_checkbutton' => \my $dash_checkbutton, 'audio_only_checkbutton' => \my $audio_only_checkbutton, - 'gif_spinner' => \my $gif_spinner, 'hbox2' => \my $hbox2, 'feeds_title' => \my $feeds_title, 'channel_name_save' => \my $save_channel_name_entry, @@ -422,11 +465,10 @@ local $SIG{__DIE__} = sub { #---------------------- LOAD IMAGES ----------------------# my $app_icon_pixbuf = 'Gtk3::Gdk::Pixbuf'->new_from_file(catfile($icons_path, "gtk-fair-viewer.png")); -my $user_icon_pixbuf = 'Gtk3::Gdk::Pixbuf'->new_from_file_at_size(catfile($icons_path, "user.png"), 16, 16); -my $feed_icon_pixbuf = 'Gtk3::Gdk::Pixbuf'->new_from_file_at_size(catfile($icons_path, "feed.png"), 16, 16); -my $feed_icon_gray_pixbuf = 'Gtk3::Gdk::Pixbuf'->new_from_file_at_size(catfile($icons_path, "feed_gray.png"), 16, 16); +my $user_icon_pixbuf = 'Gtk3::Gdk::Pixbuf'->new_from_file_at_size(catfile($icons_path, "user.png"), 16, 16); +my $feed_icon_pixbuf = 'Gtk3::Gdk::Pixbuf'->new_from_file_at_size(catfile($icons_path, "feed.png"), 16, 16); +my $feed_icon_gray_pixbuf = 'Gtk3::Gdk::Pixbuf'->new_from_file_at_size(catfile($icons_path, "feed_gray.png"), 16, 16); my $default_thumb = 'Gtk3::Gdk::Pixbuf'->new_from_file_at_size(catfile($icons_path, "default_thumb.jpg"), 160, 90); -my $animation = 'Gtk3::Gdk::PixbufAnimation'->new_from_file(catfile($icons_path, "spinner.gif")); # Setting application title and icon $mainw->set_title("$appname $version"); @@ -458,6 +500,7 @@ if (not defined $CONFIG{cache_dir}) { $CONFIG{cache_dir} = catdir($cache_dir, 'fair-viewer'); } +# Create the cache directory (if needed) foreach my $path ($CONFIG{cache_dir}) { next if -d $path; require File::Path; @@ -550,10 +593,12 @@ foreach my $path ($CONFIG{cache_dir}) { my $word = $words[$i]; for (my $j = $i ; $j <= $end_p ; ++$j) { - my $part = $parts[$j]; my $matched; my $continue = 1; + + my $part = $parts[$j]; + while ($part eq $word) { $order_score += 1 - 1 / (length($word) + 1)**2; $matched ||= 1; @@ -562,8 +607,9 @@ foreach my $path ($CONFIG{cache_dir}) { } if ($matched) { - $order_score += 1 - 1 / (length($word) + 1) - if ($continue and index($part, $word) == 0); + if ($continue and index($part, $word) == 0) { + $order_score += 1 - 1 / (length($word) + 1); + } last; } elsif (index($part, $word) == 0) { @@ -687,8 +733,8 @@ foreach my $path ($CONFIG{cache_dir}) { search(); } ); - $item->set_property(tooltip_text => $text); - $item->set_image('Gtk3::Image'->new_from_icon_name("history-view", q{menu})); + $item->set_property(tooltip_text => "Search for „${text}”"); + $item->set_image('Gtk3::Image'->new_from_icon_name("system-search", q{menu})); $item->show; $history_menu->append($item); } @@ -806,19 +852,19 @@ my %ResultsHistory = ( ); # Locate CLI fair-viewer -$CONFIG{fair_viewer} //= which_command('fair-viewer') // 'fair-viewer'; +$CONFIG{pipe_viewer} //= which_command('fair-viewer') // 'fair-viewer'; my $yv_obj = WWW::FairViewer->new( - escape_utf8 => 1, - config_dir => $config_dir, - ytdl => $CONFIG{ytdl}, - ytdl_cmd => $CONFIG{ytdl_cmd}, - env_proxy => $CONFIG{env_proxy}, - cache_dir => $CONFIG{cache_dir}, - cookie_file => $CONFIG{cookie_file}, - user_agent => $CONFIG{user_agent}, - timeout => $CONFIG{timeout}, - ); + escape_utf8 => 1, + config_dir => $config_dir, + ytdl => $CONFIG{ytdl}, + ytdl_cmd => $CONFIG{ytdl_cmd}, + env_proxy => $CONFIG{env_proxy}, + cache_dir => $CONFIG{cache_dir}, + cookie_file => $CONFIG{cookie_file}, + user_agent => $CONFIG{user_agent}, + timeout => $CONFIG{timeout}, + ); #$yv_obj->load_authentication_tokens(); @@ -831,7 +877,7 @@ else { require WWW::FairViewer::Utils; my $yv_utils = WWW::FairViewer::Utils->new(thousand_separator => $CONFIG{thousand_separator}, - youtube_url_format => $CONFIG{youtube_video_url},); + youtube_url_format => $CONFIG{youtube_video_url},); # Set default combobox values $definition_combobox->set_active(0); @@ -851,7 +897,7 @@ sub apply_configuration { $audio_only_checkbutton->set_active($CONFIG{audio_only}); # DASH mode - $dash_checkbutton->set_active($CONFIG{dash_segmented}); + $dash_checkbutton->set_active($CONFIG{dash}); $clear_list_checkbutton->set_active($CONFIG{clear_search_list}); $panel_account_type_combobox->set_active($CONFIG{active_panel_account_combobox}); @@ -862,11 +908,11 @@ sub apply_configuration { foreach my $option_name ( qw( - comments_order maxResults videoDimension videoLicense region debug http_proxy user_agent timeout cookie_file ytdl ytdl_cmd api_host prefer_mp4 prefer_av1 + comments_order prefer_invidious ) ) { @@ -960,6 +1006,12 @@ sub set_text { my ($object, $text, %args) = @_; my $object_buffer = $object->get_buffer; + require Encode; + + if (!Encode::is_utf8($text)) { + $text = Encode::decode_utf8($text); + } + if ($args{append}) { my $iter = $object_buffer->get_end_iter; $object_buffer->insert($iter, $text); @@ -1031,21 +1083,22 @@ sub menu_popup { # Create the main right-click menu my $menu = 'Gtk3::Menu'->new; + # More details + { + my $item = 'Gtk3::ImageMenuItem'->new("Show more details"); + $item->set_image('Gtk3::Image'->new_from_icon_name("window-new", q{menu})); + $item->signal_connect(activate => \&show_details_window); + $item->show; + $menu->append($item); + } + # Video menu if ($type eq 'video') { - my $video_id = $liststore->get($iter, 3); - - # More details - { - my $item = 'Gtk3::ImageMenuItem'->new("Show more details"); - $item->set_image('Gtk3::Image'->new_from_icon_name("window-new", q{menu})); - $item->signal_connect(activate => \&show_details_window); - $item->show; - $menu->append($item); - } + my $video_id = $liststore->get($iter, 3); + my $video_data = $yv_obj->parse_json_string($liststore->get($iter, 8)); - # Fair comments + # Youtube comments { my $item = 'Gtk3::ImageMenuItem'->new("YouTube comments"); $item->set_image('Gtk3::Image'->new_from_icon_name("edit-copy", q{menu})); @@ -1091,11 +1144,11 @@ sub menu_popup { # Favorite { my $item = 'Gtk3::ImageMenuItem'->new("Favorite"); - $item->set_property(tooltip_text => "Save the video to favorites"); + $item->set_property(tooltip_text => "Save the video in the playlist of favorite videos"); $item->signal_connect( activate => sub { - $yv_obj->favorite_video($video_id) - or warn "Failed to favorite the video <$video_id>: $!"; + say(":: Favorite video: ", $yv_utils->get_title($video_data)) if $yv_obj->get_debug; + prepend_video_data_to_file($video_data, $favorite_videos_data_file); } ); $item->set_image('Gtk3::Image'->new_from_icon_name("starred-symbolic", q{menu})); @@ -1123,11 +1176,11 @@ sub menu_popup { # Like { my $item = 'Gtk3::ImageMenuItem'->new("Like"); - $item->set_property(tooltip_text => "Send a positive rating"); + $item->set_property(tooltip_text => "Save video in the playlist of liked videos"); $item->signal_connect( activate => sub { - $yv_obj->send_rating_to_video($video_id, 'like') - or warn "Failed to send a positive rating to <$video_id>: $!"; + say(":: Liking video: ", $yv_utils->get_title($video_data)) if $yv_obj->get_debug; + prepend_video_data_to_file($video_data, $liked_videos_data_file); } ); $item->set_image('Gtk3::Image'->new_from_icon_name("go-up-symbolic", q{menu})); @@ -1138,11 +1191,11 @@ sub menu_popup { # Disike { my $item = 'Gtk3::ImageMenuItem'->new("Dislike"); - $item->set_property(tooltip_text => "Send a negative rating"); + $item->set_property(tooltip_text => "Save video in the playlist of disliked videos"); $item->signal_connect( activate => sub { - $yv_obj->send_rating_to_video($video_id, 'dislike') - or warn "Failed to send a negative rating to <$video_id>: $!"; + say(":: Disliking video: ", $yv_utils->get_title($video_data)) if $yv_obj->get_debug; + prepend_video_data_to_file($video_data, $disliked_videos_data_file); } ); $item->set_image('Gtk3::Image'->new_from_icon_name("go-down-symbolic", q{menu})); @@ -1185,7 +1238,7 @@ sub menu_popup { my $playlist_id = $liststore->get($iter, 3); - # More details + # Playlist videos { my $item = 'Gtk3::ImageMenuItem'->new("Videos"); $item->set_property(tooltip_text => "Display the videos from this playlist"); @@ -1232,6 +1285,16 @@ sub menu_popup { $author->append($item); } + # Playlists created by this author + { + my $item = 'Gtk3::ImageMenuItem'->new("Playlists"); + $item->signal_connect(activate => \&show_playlists_from_selected_author); + $item->set_property(tooltip_text => "Show playlists created by this author"); + $item->set_image('Gtk3::Image'->new_from_icon_name("emblem-documents-symbolic", q{menu})); + $item->show; + $author->append($item); + } + # Favorites of this author { my $item = 'Gtk3::ImageMenuItem'->new("Favorites"); @@ -1254,16 +1317,6 @@ sub menu_popup { #~ } #>>> - # Playlists created by this author - { - my $item = 'Gtk3::ImageMenuItem'->new("Playlists"); - $item->signal_connect(activate => \&show_playlists_from_selected_author); - $item->set_property(tooltip_text => "Show playlists created by this author"); - $item->set_image('Gtk3::Image'->new_from_icon_name("emblem-documents-symbolic", q{menu})); - $item->show; - $author->append($item); - } - # Liked videos by this author #<<< #~ { @@ -1283,21 +1336,29 @@ sub menu_popup { $author->append($item); } + my $channel_data = $yv_obj->parse_json_string($liststore->get($iter, 8)); + my $channel_name = $yv_utils->get_channel_title($channel_data); + # Subscribe to channel { my $item = 'Gtk3::ImageMenuItem'->new("Subscribe"); - $item->signal_connect( - activate => sub { - $yv_obj->subscribe_channel($channel_id) - or warn "Failed to subscribe to channel <$channel_id>: $!"; - } - ); + $item->signal_connect(activate => sub { save_channel_by_id($channel_id, $channel_name, subscribe => 1) }); $item->set_property(tooltip_text => "Subscribe to this channel"); $item->set_image('Gtk3::Image'->new_from_pixbuf($feed_icon_gray_pixbuf)); $item->show; $author->append($item); } + # Save channel in the user-list + { + my $item = 'Gtk3::ImageMenuItem'->new("Save channel"); + $item->set_property(tooltip_text => "Save the channel in the user-list"); + $item->signal_connect(activate => sub { save_channel_by_id($channel_id, $channel_name) }); + $item->set_image('Gtk3::Image'->new_from_icon_name("star-new-symbolic", q{menu})); + $item->show; + $author->append($item); + } + # Open the YouTube channel page { my $item = 'Gtk3::ImageMenuItem'->new("YouTube page"); @@ -1354,15 +1415,15 @@ sub menu_popup { my ($id, $iter) = get_selected_entry_code(); my $type = $liststore->get($iter, 7); if (defined($id) and $type eq 'video') { - execute_cli_fair_viewer("--id=$id --no-video"); + execute_cli_pipe_viewer("--id=$id --no-video"); } elsif (defined($id) and $type eq 'playlist') { - execute_cli_fair_viewer("--pp=$id --no-video"); + execute_cli_pipe_viewer("--pp=$id --no-video"); } } ); $item->set_property(tooltip_text => "Play as audio in a new terminal"); - $item->set_image('Gtk3::Image'->new_from_icon_name("multimedia-audio-player", q{menu})); + $item->set_image('Gtk3::Image'->new_from_icon_name("audio-headphones", q{menu})); $item->show; $menu->append($item); } @@ -1370,13 +1431,12 @@ sub menu_popup { # Play with CLI fair-viewer { my $item = 'Gtk3::ImageMenuItem'->new("Play in terminal"); - $item->signal_connect(activate => \&play_selected_video_with_cli_fair_viewer); + $item->signal_connect(activate => \&play_selected_video_with_cli_pipe_viewer); $item->set_property(tooltip_text => "Play with fair-viewer in a new terminal"); $item->set_image('Gtk3::Image'->new_from_icon_name("computer", q{menu})); $item->show; $menu->append($item); } - } $menu->popup(undef, undef, undef, undef, $event->button, $event->time); @@ -1385,10 +1445,69 @@ sub menu_popup { sub users_menu_popup { my ($treeview, $event) = @_; + if ($event->button != 3) { return 0; } - my $menu = $gui->get_object('user_option_menu'); + + # Hardcoded menu + #my $menu = $gui->get_object('user_option_menu'); + + # Dynamic menu + my $path = ($treeview->get_path_at_pos($event->x, $event->y))[0] // return 0; + + my $selection = $treeview->get_selection; + $selection->select_path($path); + + my $iter = $selection->get_selected() // return 0; + + my $channel_id = $users_liststore->get($iter, 0); + my $channel_name = $users_liststore->get($iter, 1); + + # Create the main right-click menu + my $menu = 'Gtk3::Menu'->new; + + # Videos from channel + { + my $item = 'Gtk3::ImageMenuItem'->new("Videos"); + $item->set_image('Gtk3::Image'->new_from_icon_name("applications-multimedia", q{menu})); + $item->set_property(tooltip_text => "List the latest videos from this channel"); + $item->signal_connect(activate => \&videos_from_selected_username); + $item->show; + $menu->append($item); + } + + # Playlists from channel + { + my $item = 'Gtk3::ImageMenuItem'->new("Playlists"); + $item->set_image('Gtk3::Image'->new_from_icon_name("emblem-documents", q{menu})); + $item->set_property(tooltip_text => "List the playlists created by this channel"); + $item->signal_connect(activate => \&playlists_from_selected_username); + $item->show; + $menu->append($item); + } + + # Subscribe / unsubscribe from channel + { + my $item = 'Gtk3::ImageMenuItem'->new($subscribed_channels{$channel_id} ? "Unsubscribe" : "Subscribe"); + $subscribed_channels{$channel_id} + ? $item->set_image('Gtk3::Image'->new_from_pixbuf($feed_icon_gray_pixbuf)) + : $item->set_image('Gtk3::Image'->new_from_pixbuf($feed_icon_pixbuf)); + $item->signal_connect(activate => \&subscribe_toggle_selected_username); + $item->show; + $menu->append($item); + } + + # Remove the channel + { + my $item = 'Gtk3::ImageMenuItem'->new("Remove"); + $item->set_image('Gtk3::Image'->new_from_icon_name("gtk-remove", q{menu})); + $item->set_property(tooltip_text => "Remove the channel from this list"); + $item->signal_connect(activate => \&remove_selected_username); + $item->show; + $menu->append($item); + } + $menu->popup(undef, undef, undef, undef, $event->button, $event->time); return 0; } @@ -1406,9 +1525,9 @@ set_text( CTRL+E : enqueue the selected video CTRL+U : show the saved user-list - CTRL+D : show more video details for a selected video + CTRL+D : show more details for a selected entry CTRL+W : show the warnings window - CTRL+G : show videos favorited by the author of a selected video + CTRL+G : show favorite videos of the author of a selected video CTRL+R : show related videos for a selected video CTRL+M : show videos from the author of a selected video CTRL+K : show playlists from the author of a selected video @@ -1419,7 +1538,7 @@ set_text( F11 : minimize-maximize the main window HELP_TEXT - ); +); { my $font = Pango::FontDescription::from_string('Monospace 8'); @@ -1436,13 +1555,13 @@ $accel->connect(ord('l'), ['control-mask'], ['visible'], \&show_login_to_youtube $accel->connect(ord('p'), ['control-mask'], ['visible'], \&show_preferences_window); $accel->connect(ord('q'), ['control-mask'], ['visible'], \&on_mainw_destroy); $accel->connect(ord('u'), ['control-mask'], ['visible'], \&show_users_list_window); -$accel->connect(ord('y'), ['control-mask'], ['visible'], \&run_cli_fair_viewer); +$accel->connect(ord('y'), ['control-mask'], ['visible'], \&run_cli_pipe_viewer); $accel->connect(ord('d'), ['control-mask'], ['visible'], \&show_details_window); #$accel->connect(ord('c'), ['control-mask'], ['visible'], \&show_comments_window); $accel->connect(ord('s'), ['control-mask'], ['visible'], \&add_user_to_favorites); $accel->connect(ord('r'), ['control-mask'], ['visible'], \&show_related_videos); -$accel->connect(ord('g'), ['control-mask'], ['visible'], \&show_user_favorited_videos); +$accel->connect(ord('g'), ['control-mask'], ['visible'], \&show_user_favorite_videos); $accel->connect(ord('m'), ['control-mask'], ['visible'], \&show_videos_from_selected_author); $accel->connect(ord('k'), ['control-mask'], ['visible'], \&show_playlists_from_selected_author); $accel->connect(ord('w'), ['control-mask'], ['visible'], \&show_warnings_window); @@ -1575,8 +1694,15 @@ sub hide_login_to_youtube_window { sub show_details_window { my ($code, $iter) = get_selected_entry_code(); $code // return; + + my $type = $liststore->get($iter, 7); + + if ($type eq 'next_page') { + return 1; + } + $details_window->show; - set_entry_details($code, $iter); + Glib::Idle->add(sub { set_entry_details($code, $iter); return 0 }, [], Glib::G_PRIORITY_LOW); return 1; } @@ -1611,7 +1737,7 @@ sub show_comments_window { return 0; }, [], - Glib::G_PRIORITY_DEFAULT_IDLE + Glib::G_PRIORITY_LOW ); return 1; @@ -1755,7 +1881,7 @@ sub toggled_audio_only { # DASH mode sub toggled_dash_support { - $CONFIG{dash_segmented} = $dash_checkbutton->get_active() || 0; + $CONFIG{dash} = $dash_checkbutton->get_active() || 0; } # Check buttons toggles @@ -1836,242 +1962,299 @@ sub add_top_row { ); } -sub set_youtube_tops { - my ($top_time, $main_label) = @_; +my $playlists_liststore = $gui->get_object('liststore6'); +my $playlists_treeview = $gui->get_object('treeview4'); + +sub add_local_playlist_row { + my ($playlist_name, $playlist_file) = @_; - ...; # Unimplemented! + my $iter = $playlists_liststore->append; - #my $iter = $tops_liststore->append; - #$tops_liststore->set($iter, 0, "<big><b>\t$main_label</b></big>"); - #add_top_row($name, $type); + $playlists_liststore->set( + $iter, + 0 => encode_entities($playlist_name), + 1 => $feed_icon_gray_pixbuf, + 2 => $playlist_name, + 3 => $playlist_file, + ); } -{ - my %channels; +sub set_local_playlists { + my ($top_time, $main_label) = @_; - # ------------ Usernames list window ------------ # - sub set_usernames { - if (-e $CONFIG{youtube_users_file}) { - if (open my $fh, '<:utf8', $CONFIG{youtube_users_file}) { - while (defined(my $entry = <$fh>)) { + my @playlist_files = reverse $yv_utils->get_local_playlist_filenames($local_playlists_dir); - $entry = unpack('A*', $entry); - my ($channel, $label) = split(' ', $entry, 2); + foreach my $file (@playlist_files) { + my $snippet = $yv_utils->local_playlist_snippet($file); + add_local_playlist_row($yv_utils->get_title($snippet), $file); + } +} - if (defined($channel) and $channel =~ /$valid_channel_id_re/) { - $channel = $+{channel_id}; - if (defined($label) and $label =~ /\S/) { - $channels{$channel} = $label; - } - else { - $channels{$channel} = undef; - } - } - } - close $fh; - } - } - else { - # Default channels - %channels = ( - 'UC1_uAIS3r8Vu6JjXWvastJg' => 'Mathologer', - 'UCSju5G2aFaWMqn-_0YBtq5A' => 'StandUpMaths', - 'UCW6TXMZ5Pq6yL6_k5NZ2e0Q' => 'Socratica', - 'UC-WICcSW1k3HsScuXxDrp0w' => 'Curry On!', - 'UCShHFwKyhcDo3g7hr4f1R8A' => 'World Science Festival', - 'UCYO_jab_esuFRV4b17AJtAw' => '3Blue1Brown', - 'UCWnPjmqvljcafA0z2U1fwKQ' => 'Confreaks', - 'UC_QIfHvN9auy2CoOdSfMWDw' => 'Strange Loop', - 'UCH4BNI0-FOK2dMXoFtViWHw' => "It's Okay To Be Smart", - 'UCHnyfMqiRRG1u-2MsSQLbXA' => 'Veritasium', - 'UCseUQK4kC3x2x543nHtGpzw' => 'Brian Will', - 'UC9-y-6csu5WGm29I7JiwpnA' => 'Computerphile', - 'UCoxcjq-8xIDTYp3uz647V5A' => 'Numberphile', - 'UC6nSFpj9HTCZ5t-N3Rm3-HA' => 'Vsauce', - 'UC4a-Gbdw7vOaccHmFo40b9g' => 'Khan Academy', - 'UCUHW94eEFW7hkUMVaZz4eDg' => 'MinutePhysics', - 'UCYeF244yNGuFefuFKqxIAXw' => 'The Royal Institution', - 'UCX6b17PVsYBQ0ip5gyeme-Q' => 'CrashCourse', - 'UCwbsWIWfcOL2FiUZ2hKNJHQ' => 'UCBerkeley', - 'UCEBb1b_L6zDS3xTUrIALZOw' => 'MIT OpenCourseWare', - 'UCAuUUnT6oDeKwE6v1NGQxug' => 'TED', - 'UCvBqzzvUBLCs8Y7Axb-jZew' => 'Sixty Symbols', - 'UC6107grRI4m0o2-emgoDnAA' => 'SmarterEveryDay', - 'UCZYTClx2T1of7BRZ86-8fow' => 'SciShow', - 'UCF6F8LdCSWlRwQm_hfA2bcQ' => 'Coding Math', - 'UC1znqKFL3jeR0eoA0pHpzvw' => 'SpaceRip', - 'UCvjgXvBlbQiydffZU7m1_aw' => 'Daniel Shiffman', - 'UCC552Sd-3nyi_tk2BudLUzA' => 'AsapSCIENCE', - 'UC0wbcfzV-bHhABbWGXKHwdg' => 'Utah Open Source', - 'UCotwjyJnb-4KW7bmsOoLfkg' => 'Art of the Problem', - 'UC7y4qaRSb5w2O8cCHOsKZDw' => 'YAPC NA', - ); - } +set_local_playlists(); - foreach my $channel (sort { ($channels{$a} // lc($a)) cmp($channels{$b} // lc($b)) } keys %channels) { - my $iter = $users_liststore->append; +# ------------ Usernames list window ------------ # +sub set_usernames { + if (-e $CONFIG{youtube_users_file}) { + %channels = ( + %channels, + ( + map { @$_ } + grep { not exists $removed_channels{$_->[0]} } + $yv_utils->read_channels_from_file($CONFIG{youtube_users_file}) + ) + ); + } + else { + # Default channels + %channels = ( + 'UC1_uAIS3r8Vu6JjXWvastJg' => 'Mathologer', + 'UCSju5G2aFaWMqn-_0YBtq5A' => 'Stand-Up Maths', + 'UC-WICcSW1k3HsScuXxDrp0w' => 'Curry On!', + 'UCShHFwKyhcDo3g7hr4f1R8A' => 'World Science Festival', + 'UCYO_jab_esuFRV4b17AJtAw' => '3Blue1Brown', + 'UCWnPjmqvljcafA0z2U1fwKQ' => 'Confreaks', + 'UC_QIfHvN9auy2CoOdSfMWDw' => 'Strange Loop', + 'UCseUQK4kC3x2x543nHtGpzw' => 'Brian Will', + 'UC9-y-6csu5WGm29I7JiwpnA' => 'Computerphile', + 'UCoxcjq-8xIDTYp3uz647V5A' => 'Numberphile', + 'UCvBqzzvUBLCs8Y7Axb-jZew' => 'Sixty Symbols', + 'UC6107grRI4m0o2-emgoDnAA' => 'SmarterEveryDay', + 'UCF6F8LdCSWlRwQm_hfA2bcQ' => 'Coding Math', + 'UC1znqKFL3jeR0eoA0pHpzvw' => 'SpaceRip', + 'UCvjgXvBlbQiydffZU7m1_aw' => 'The Coding Train', + 'UC0wbcfzV-bHhABbWGXKHwdg' => 'Utah Open Source', + 'UCotwjyJnb-4KW7bmsOoLfkg' => 'Art of the Problem', + 'UC7y4qaRSb5w2O8cCHOsKZDw' => 'YAPC NA', + 'UCGHZpIpAWJQ-Jy_CeCdXhMA' => 'Cool Worlds', + 'UCmG6gHgD8JaEZVxuHWJijGQ' => 'UConn Mathematics', + 'UC81mayGa63QaJE1SjKIYp0w' => 'metaRising', + 'UCSHZKyawb77ixDdsGog4iWA' => 'Lex Fridman', + 'UCBa659QWEk1AI4Tg--mrJ2A' => 'Tom Scott', + ); + } + + if (-e $CONFIG{subscribed_channels_file}) { + %subscribed_channels = ( + %subscribed_channels, + ( + map { @$_ } + grep { not exists $unsubbed_channels{$_->[0]} } + grep { not exists $removed_channels{$_->[0]} } + $yv_utils->read_channels_from_file($CONFIG{subscribed_channels_file}) + ) + ); + } + + $users_liststore->clear; # clear the list + + foreach my $channel (sort { CORE::fc($channels{$a} // $a) cmp CORE::fc($channels{$b} // $b) } keys %channels) { + my $iter = $users_liststore->append; - if (defined $channels{$channel}) { - $users_liststore->set( - $iter, - 0 => $channel, - 1 => $channels{$channel}, - 2 => 'channel', - ); - } - else { - $users_liststore->set( - $iter, - 0 => $channel, - 1 => $channel, - 2 => 'username', - ); - } + $channels{$channel} // next; - $users_liststore->set($iter, [3], [$user_icon_pixbuf]); - } + $users_liststore->set( + $iter, + 0 => $channel, + 1 => $channels{$channel}, + 2 => 'channel', + 3 => ( + exists($subscribed_channels{$channel}) + ? $feed_icon_pixbuf + : $user_icon_pixbuf + ), + ); } +} - sub save_channel { - my $channel_name = $save_channel_name_entry->get_text; - my $channel_id = $save_channel_id_entry->get_text; +sub save_channel { + my $channel_name = $save_channel_name_entry->get_text; + my $channel_id = $save_channel_id_entry->get_text; - # Validate the channel id - if (defined($channel_id) and $channel_id =~ /$valid_channel_id_re/) { + # Validate the channel id + if (defined($channel_id) and $channel_id =~ /$valid_channel_id_re/) { - $channel_id = $+{channel_id}; + $channel_id = $+{channel_id}; - # Get the channel name when empty - if (not defined($channel_name) or not $channel_name =~ /\S/) { - $channel_name = $yv_obj->channel_title_from_id($channel_id) // die "Invalid channel ID: <<$channel_id>>"; - } + # Get the channel name when empty + if (not defined($channel_name) or not $channel_name =~ /\S/) { + $channel_name = $yv_obj->channel_title_from_id($channel_id) // die "Invalid channel ID: <<$channel_id>>"; } - elsif (defined($channel_name) and $channel_name =~ /$valid_channel_id_re/) { + } + elsif (defined($channel_name) and $channel_name =~ /$valid_channel_id_re/) { - $channel_name = $+{channel_id}; - $channel_id = $yv_obj->channel_id_from_username($channel_name); + $channel_name = $+{channel_id}; + $channel_id = $yv_obj->channel_id_from_username($channel_name); - if (not defined $channel_id) { - die "Can't get channel ID from username: <<$channel_name>>"; - } - } - elsif (defined($channel_id) and $channel_id =~ /\S/) { - die "Invalid channel ID: <<$channel_id>>"; - } - else { - return; + if (not defined $channel_id) { + die "Can't get channel ID from username: <<$channel_name>>"; } + } + elsif (defined($channel_id) and $channel_id =~ /\S/) { + die "Invalid channel ID: <<$channel_id>>"; + } + else { + return; + } + + save_channel_by_id($channel_id, $channel_name); +} + +sub save_channel_by_id { + my ($channel_id, $channel_name, %args) = @_; - save_channel_by_id($channel_id, $channel_name); + # Validate the channel ID + if (not defined($channel_id) or not $channel_id =~ /$valid_channel_id_re/) { + return; } - sub save_channel_by_id { - my ($channel_id, $channel_name) = @_; + if ($channel_id =~ /$valid_channel_id_re/) { + $channel_id = $+{channel_id}; + } - # Validate the channel ID - if (not defined($channel_id) or not $channel_id =~ /$valid_channel_id_re/) { - return; - } + if ($args{subscribe} and not exists($subscribed_channels{$channel_id})) { + say ":: Subscribed channel: $channel_name" if $yv_obj->get_debug; + $subscribed_channels{$channel_id} = $channel_name; + write_channels_to_file(\%subscribed_channels, $CONFIG{subscribed_channels_file}); + set_usernames(); + } - if ($channel_id =~ /$valid_channel_id_re/) { - $channel_id = $+{channel_id}; - } + # Channel ID already exists in the list + if (exists($channels{$channel_id})) { + return; + } - # Channel ID already exists in the list - if (exists($channels{$channel_id})) { - return; - } + # Get the channel name + if (not defined($channel_name) or not $channel_name =~ /\S/) { + $channel_name = $yv_obj->channel_title_from_id($channel_id) // $channel_id; + } - # Get the channel name - if (not defined($channel_name) or not $channel_name =~ /\S/) { - $channel_name = $yv_obj->channel_title_from_id($channel_id) // $channel_id; - } + # Store it internally + $channels{$channel_id} = $channel_name; - # Store it internally - $channels{$channel_id} = $channel_name; + # Append it to the list + my $iter = $users_liststore->append; - # Append it to the list - my $iter = $users_liststore->append; + $users_liststore->set( + $iter, + 0 => $channel_id, + 1 => $channel_name, + 2 => 'channel', + 3 => $user_icon_pixbuf, + ); +} - $users_liststore->set( - $iter, - 0 => $channel_id, - 1 => $channel_name, - 2 => 'channel', - 3 => $user_icon_pixbuf, - ); - } +sub add_user_to_favorites { + my $selection = $treeview->get_selection() // return; + my $iter = $selection->get_selected() // return; - sub add_user_to_favorites { - my $channel_id = get_channel_id_for_selected_video() // return; - save_channel_by_id($channel_id); - } + my $info = $yv_obj->parse_json_string($liststore->get($iter, 8)); - sub remove_selected_user { - my $selection = $users_treeview->get_selection // return; - my $iter = $selection->get_selected // return; - my $channel_id = $users_liststore->get($iter, 0); - delete $channels{$channel_id}; - $users_liststore->remove($iter); - } + my $channel_id = $liststore->get($iter, 6); + my $channel_name = $yv_utils->get_channel_title($info); - sub save_usernames_to_file { - open(my $fh, '>:utf8', $CONFIG{youtube_users_file}) or return; - foreach my $channel ( - sort { ($channels{$a} // $a) cmp($channels{$b} // $b) } - keys %channels - ) { - if (defined($channels{$channel})) { - say $fh "$channel $channels{$channel}"; - } - else { - say $fh $channel; - } - } - close $fh; + save_channel_by_id($channel_id, $channel_name); +} + +sub subscribe_toggle_selected_username { + + my $selection = $users_treeview->get_selection // return; + my $iter = $selection->get_selected // return; + + my $channel_id = $users_liststore->get($iter, 0); + my $channel_name = $users_liststore->get($iter, 1); + + if (exists $subscribed_channels{$channel_id}) { + $unsubbed_channels{$channel_id} = 1; + delete $subscribed_channels{$channel_id}; + $users_liststore->set($iter, [3], [$user_icon_pixbuf]); } + else { + $subscribed_channels{$channel_id} = $channel_name; + delete $unsubbed_channels{$channel_id}; + $users_liststore->set($iter, [3], [$feed_icon_pixbuf]); + } + + write_channels_to_file(\%subscribed_channels, $CONFIG{subscribed_channels_file}); +} + +sub remove_selected_username { + + my $selection = $users_treeview->get_selection // return; + my $iter = $selection->get_selected // return; + my $channel_id = $users_liststore->get($iter, 0); + + delete $channels{$channel_id}; + delete $subscribed_channels{$channel_id}; + + $removed_channels{$channel_id} = 1; + $users_liststore->remove($iter); +} - # Get playlists from username - sub playlists_from_selected_username { - my $selection = $users_treeview->get_selection() // return; - my $iter = $selection->get_selected() // return; +sub write_channels_to_file { + my ($channels, $file) = @_; - my $type = $users_liststore->get($iter, 2); - my $channel = $users_liststore->get($iter, 0); + open(my $fh, '>:utf8', $file) or return; - playlists($type, $channel); + foreach my $channel ( + sort { CORE::fc($channels->{$a} // $a) cmp CORE::fc($channels->{$b} // $b) } + keys %$channels + ) { + if (defined($channels->{$channel})) { + say $fh "$channel $channels->{$channel}"; + } + else { + say $fh "$channel $channel"; + } } - sub videos_from_selected_username { - my $selection = $users_treeview->get_selection() // return; - my $iter = $selection->get_selected() // return; + close $fh; +} + +sub save_usernames_to_file { - my $type = $users_liststore->get($iter, 2); - my $channel = $users_liststore->get($iter, 0); + set_usernames(); # update %channels - uploads($type, $channel); + foreach my $id (keys %removed_channels) { + delete $channels{$id}; + delete $subscribed_channels{$id}; } - sub videos_from_saved_channel { - hide_users_list_window(); - videos_from_selected_username(); + foreach my $id (keys %unsubbed_channels) { + delete $subscribed_channels{$id}; } + + write_channels_to_file(\%channels, $CONFIG{youtube_users_file}); + write_channels_to_file(\%subscribed_channels, $CONFIG{subscribed_channels_file}); } -# ----- My panel settings ----- # -sub log_out { - change_subscription_page(0); +# Get playlists from username +sub playlists_from_selected_username { + my $selection = $users_treeview->get_selection() // return; + my $iter = $selection->get_selected() // return; + + my $type = $users_liststore->get($iter, 2); + my $channel = $users_liststore->get($iter, 0); - unlink $authentication_file - or warn "Can't unlink: `$authentication_file' -> $!"; + playlists($type, $channel); +} - $yv_obj->set_access_token(); - $yv_obj->set_refresh_token(); +sub videos_from_selected_username { + my $selection = $users_treeview->get_selection() // return; + my $iter = $selection->get_selected() // return; - $statusbar->push(1, "Not logged in."); - return 1; + my $type = $users_liststore->get($iter, 2); + my $channel = $users_liststore->get($iter, 0); + + uploads($type, $channel); } +sub videos_from_saved_channel { + hide_users_list_window(); + videos_from_selected_username(); +} + +# ----- My panel settings ----- # + sub change_subscription_page { my ($value) = @_; foreach my $object (qw(subsc_scrollwindow subsc_label)) { @@ -2313,12 +2496,24 @@ sub get_code { my $type = $liststore->get($iter, 7); - $type eq 'playlist' ? list_playlist($code) - : ($type eq 'channel' || $type eq 'subscription') ? uploads('channel', $code) - : $type eq 'next_page' && $code ne '' ? do { + if ($type eq 'playlist') { + list_playlist($code); + } + elsif ($type eq 'channel' or $type eq 'subscription') { + uploads('channel', $code); + } + elsif ($type eq 'next_page' and $code ne '') { + my $results; my $next_page_token = $liststore->get($iter, 5); - my $results = $yv_obj->next_page($code, $next_page_token); + + if ($next_page_token =~ /^json (.*)/s) { + my $data = $yv_obj->parse_json_string($1); + $results = get_results_from_list(%$data); + } + else { + $results = $yv_obj->next_page($code, $next_page_token); + } if ($yv_utils->has_entries($results)) { my $label = '<big><b>' . ('=' x 20) . '</b></big>'; @@ -2330,18 +2525,17 @@ sub get_code { } display_results($results); - } - : $type eq 'video' ? ( - $CONFIG{audio_only} - ? execute_cli_fair_viewer("--id=$code") - : play_video($yv_obj->parse_json_string($liststore->get($iter, 8))) - ) - : (); + } + elsif ($type eq 'video') { + $CONFIG{audio_only} + ? execute_cli_pipe_viewer("--id=$code") + : play_video($yv_obj->parse_json_string($liststore->get($iter, 8))); + } return 0; }, [$code, $iter], - Glib::G_PRIORITY_DEFAULT_IDLE + Glib::G_PRIORITY_LOW ); } @@ -2350,18 +2544,25 @@ sub make_row_description { } sub append_next_page { - my ($url, $continuation) = @_; + my ($url, $token) = @_; + + if (ref($token) ne 'CODE') { + $url // return; + } + + $token // return; # no next page is available - $url // return; my $iter = $liststore->append; $liststore->set( $iter, 0 => "<big><b>LOAD MORE</b></big>", 3 => $url, - 5 => $continuation, + 5 => $token, 7 => 'next_page', ); + + return $iter; } sub determine_image_format { @@ -2403,7 +2604,7 @@ sub get_pixbuf_thumbnail_from_content { require Digest::MD5; - my $md5 = Digest::MD5::md5_hex($thumbnail); + my $md5 = Digest::MD5::md5_hex($thumbnail // return $default_thumb); my $key = "$md5 $xsize $ysize"; state %cache; @@ -2487,6 +2688,178 @@ sub get_pixbuf_thumbnail_from_entry { return $pixbuf; } +sub get_results_from_list { + my (%args) = @_; + + $args{entries} //= []; + $args{page} //= $yv_obj->get_page; + + my @results = @{$args{entries}}; + + 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]; + } + + 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}; + + if ($args{page} * $maxResults < $totalResults) { + $results{continuation} = 'json ' + . $yv_obj->make_json_string( + { + %args, page => $args{page} + 1, + } + ); + } + + scalar {results => \%results, url => 'file'}; +} + +sub videos_from_data_file { + my ($file, %args) = @_; + + my $videos = eval { Storable::retrieve($file) } // []; + + if ($args{reverse}) { + $videos = [reverse @$videos]; + } + + foreach my $entry (@$videos) { + if (ref($entry->{timestamp} // '') eq 'Time::Piece') { + $entry->{timestamp} = [@{$entry->{timestamp}}]; + } + } + + get_results_from_list(entries => $videos); +} + +sub get_subscription_video_results { + + # 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 $CONFIG{subscribed_channels_file})) { + return videos_from_data_file($subscription_videos_data_file); + } + + my @channels = $yv_utils->read_channels_from_file($CONFIG{subscribed_channels_file}); + + if (not @channels) { + warn "\n[!] No subscribed channels...\n"; + return get_results_from_list(entries => []); + } + + my %subscriptions; + + 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]...\n", $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; + } + + 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 = $CONFIG{subscriptions_limit} // 1e4; + if ($subscriptions_limit > 0 and scalar(@$subscriptions_data) > $subscriptions_limit) { + $#$subscriptions_data = $subscriptions_limit; + } + + foreach my $entry (@$subscriptions_data) { + if (ref($entry->{timestamp} // '') eq 'Time::Piece') { + $entry->{timestamp} = [@{$entry->{timestamp}}]; + } + } + + if (@$subscriptions_data) { + Storable::store($subscriptions_data, $subscription_videos_data_file); + } + + get_results_from_list(entries => $subscriptions_data); +} + +sub get_watched_video_results { + videos_from_data_file($watch_history_data_file); +} + +sub display_watched_videos { + $liststore->clear if $CONFIG{clear_search_list}; + display_results(get_watched_video_results()); +} + +sub display_subscription_videos { + $liststore->clear if $CONFIG{clear_search_list}; + display_results(get_subscription_video_results()); +} + sub display_results { my ($results, $from_history) = @_; @@ -2495,9 +2868,6 @@ sub display_results { #my $info = $results->{results} // {}; my $items = $results->{results} // []; - #use Data::Dump qw(pp); - #pp $items; - if (ref($items) eq 'HASH') { if (exists $items->{videos}) { @@ -2506,22 +2876,22 @@ sub display_results { elsif (exists $items->{playlists}) { $items = $items->{playlists}; } + elsif (exists $items->{channels}) { + $items = $items->{channels}; + } + elsif (exists $items->{entries}) { + $items = $items->{entries}; + } else { warn "No results...\n"; } } if (ref($items) ne 'ARRAY') { - - my $current_instance = $yv_obj->get_api_host(); - - # Server error. Pick another invidious instance. - $yv_obj->pick_and_set_random_instance(); - - die "Probably $current_instance is down.\n" + die "Probably the selected invidious instance is down.\n" . "\nTry changing the `api_host` in configuration file:\n\n" . qq{\tapi_host => "auto",\n} - . qq{\nSee also: https://libregit.org/heckyel/fair-viewer#invidious-instances\n}; + . qq{\nSee also: https://git.sr.ht/~heckyel/fair-viewer#invidious-instances\n}; } if (not $yv_utils->has_entries($results)) { @@ -2682,7 +3052,7 @@ sub set_thumbnail { return 0; }, [$entry, $liststore, $iter], - Glib::G_PRIORITY_DEFAULT_IDLE + Glib::G_PRIORITY_LOW ); } @@ -2701,14 +3071,14 @@ sub add_subscription_entry { '<big><b>' . encode_entities($title) . "</b></big>\n\n" - . "<b>$symbols{face}\t</b> " + . "$symbols{author}\t " . encode_entities($channel_id) . "\n" - . "<b>$symbols{crazy_arrow}\t</b> " - . $yv_utils->get_publication_date($subscription) + . "$symbols{published}\t " + . ($yv_utils->get_publication_date($subscription) // 'unknown') . "\n\n<i>" . encode_entities($row_description) . '</i>'; - my $type_label = "<b>$symbols{diamond}</b> " . 'Subscription' . "\n"; + my $type_label = "$symbols{type}\t " . 'Subscription' . "\n"; $liststore->set( $iter, @@ -2718,6 +3088,7 @@ sub add_subscription_entry { 4 => encode_entities($description), 6 => $channel_id, 7 => 'subscription', + 8 => $yv_obj->make_json_string($subscription), ); if ($CONFIG{show_thumbs}) { @@ -2742,30 +3113,30 @@ sub add_video_entry { set_entry_tooltip($iter, $title, $description); - my $title_label = - reflow_text( "<big><b>" - . encode_entities($title) - . "</b></big>\n" - . "<b>$symbols{up_arrow}\t</b> " - . $yv_utils->set_thousands($yv_utils->get_likes($video)) . "\n" - . "<b>$symbols{down_arrow}\t</b> " - . $yv_utils->set_thousands($yv_utils->get_dislikes($video)) . "\n" - . "<b>$symbols{ellipsis}\t</b> " - . encode_entities($yv_utils->get_category_name($video)) . "\n" - . "<b>$symbols{face}\t</b> " - . encode_entities($yv_utils->get_channel_title($video)) . "\n" . "<i>" - . encode_entities($row_description) - . "</i>"); - - my $info_label = - reflow_text( "<b>$symbols{play}\t</b> " - . $yv_utils->get_time($video) . "\n" - . "<b>$symbols{diamond}\t</b> " - . $yv_utils->get_definition($video) . "\n" - . "<b>$symbols{views}\t</b> " - . $yv_utils->set_thousands($yv_utils->get_views($video)) . "\n" - . "<b>$symbols{right_arrow}\t </b>" - . $yv_utils->get_publication_date($video)); + my $title_label = reflow_text( + sprintf( + "<big><b>%s</b></big> + +$symbols{author}\t %s +$symbols{published}\t %s + +<i>%s</i>", + + encode_entities($title), + encode_entities($yv_utils->get_channel_title($video)), + ($yv_utils->get_publication_date($video) // 'unknown'), + encode_entities($row_description), + ) + ); + + my $info_label = reflow_text( + sprintf( + "$symbols{play}\t %s +$symbols{views}\t %s", + $yv_utils->get_time($video), + $yv_utils->set_thousands($yv_utils->get_views($video)), + ) + ); $liststore->set( $iter, @@ -2794,21 +3165,31 @@ sub add_channel_entry { set_entry_tooltip($iter, $title, $description); - my $title_label = - reflow_text( '<big><b>' - . encode_entities($title) - . "</b></big>\n\n" - . "<b>$symbols{face}\t</b> " - . encode_entities($yv_utils->get_channel_title($channel)) . "\n" - . "<b>$symbols{play}\t</b> " - . encode_entities($channel_id) . "\n" - . "<b>$symbols{crazy_arrow}\t</b> " - . $yv_utils->get_publication_date($channel) - . "\n\n<i>" - . encode_entities($row_description) - . '</i>'); - - my $type_label = reflow_text("<b>$symbols{diamond}</b> " . 'Channel' . "\n"); + my $title_label = reflow_text( + sprintf( + "<big><b>%s</b></big> + +$symbols{author}\t %s +$symbols{author_id}\t %s + +<i>%s</i>", + encode_entities($title), + encode_entities($title), + encode_entities($channel_id), + encode_entities($row_description), + ) + ); + + my $type_label = reflow_text( + sprintf( + "$symbols{type}\t Channel +$symbols{video}\t %s videos +$symbols{subs}\t %s subs", + + $yv_utils->set_thousands($yv_utils->get_video_count($channel)), + $yv_utils->short_human_number($yv_utils->get_subscriber_count($channel)), + ) + ); $liststore->set( $iter, @@ -2818,6 +3199,7 @@ sub add_channel_entry { 4 => encode_entities($description), 6 => $channel_id, 7 => 'channel', + 8 => $yv_obj->make_json_string($channel), ); if ($CONFIG{show_thumbs}) { @@ -2838,23 +3220,29 @@ sub add_playlist_entry { set_entry_tooltip($iter, $title, $description); - my $title_label = - reflow_text( '<big><b>' - . encode_entities($title) - . "</b></big>\n\n" - . "<b>$symbols{face}\t</b> " - . encode_entities($channel_title) . "\n" - . "<b>$symbols{play}\t</b> " - . encode_entities($playlist_id) . "\n" - . "<b>$symbols{crazy_arrow}\t</b> " - . $yv_utils->get_publication_date($playlist) . "\n\n" . '<i>' - . encode_entities($row_description) - . '</i>'); - - my $num_items_template = "<b>$symbols{numero}</b> %d items\n"; - my $num_items_text = sprintf($num_items_template, $yv_utils->get_playlist_video_count($playlist)); - - my $type_label = reflow_text("<b>$symbols{diamond}</b> " . 'Playlist' . "\n" . $num_items_text); + my $title_label = reflow_text( + sprintf( + "<big><b>%s</b></big> + +$symbols{author}\t %s +$symbols{play}\t %s + +<i>%s</i>", + + encode_entities($title), + encode_entities($channel_title), + encode_entities($playlist_id), + encode_entities($row_description), + ) + ); + + my $type_label = reflow_text( + sprintf( + "$symbols{type}\t Playlist +$symbols{video}\t %s videos", + $yv_utils->set_thousands($yv_utils->get_playlist_video_count($playlist) // 0) + ) + ); $liststore->set( $iter, @@ -2864,6 +3252,7 @@ sub add_playlist_entry { 4 => encode_entities($description), 6 => $channel_id, 7 => 'playlist', + 8 => $yv_obj->make_json_string($playlist), ); if ($CONFIG{show_thumbs}) { @@ -2996,12 +3385,12 @@ sub get_streaming_url { if (ref($captions) eq 'ARRAY' and @$captions and $CONFIG{get_captions}) { require WWW::FairViewer::GetCaption; my $yv_cap = WWW::FairViewer::GetCaption->new( - auto_captions => $CONFIG{auto_captions}, - captions_dir => $CONFIG{cache_dir}, - captions => $captions, - languages => $CONFIG{srt_languages}, - yv_obj => $yv_obj, - ); + auto_captions => $CONFIG{auto_captions}, + captions_dir => $CONFIG{cache_dir}, + captions => $captions, + languages => $CONFIG{srt_languages}, + yv_obj => $yv_obj, + ); $srt_file = $yv_cap->save_caption($video_id); } @@ -3015,9 +3404,11 @@ sub get_streaming_url { hfr => $CONFIG{hfr}, ignore_av1 => $CONFIG{ignore_av1}, - dash => $CONFIG{dash_support}, - dash_mp4_audio => $CONFIG{dash_mp4_audio}, - dash_segmented => $CONFIG{dash_segmented}, + split => $CONFIG{split_videos}, + prefer_m4a => $CONFIG{prefer_m4a}, + dash => $CONFIG{dash}, + + ignored_projections => $CONFIG{ignored_projections}, ); return { @@ -3083,6 +3474,54 @@ sub get_player_command { $has_video ? $cmd : join(' ', $cmd, quotemeta($streaming->{streaming}{url})); } +sub prepend_video_data_to_file { + my ($video_data, $file) = @_; + + my $videos = eval { Storable::retrieve($file) } // []; + + if (ref($video_data) ne 'HASH') { + return; + } + + $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) = @_; + + # Store the video title to history (when `save_watched_to_history` is true) + if ($CONFIG{save_watched_to_history}) { + append_to_history($yv_utils->get_title($video_data), 0); + } + + if ($CONFIG{watch_history}) { + + say ":: Saving video <<$video_id>> to watch history..." if ($yv_obj->get_debug); + + if (not exists($WATCHED_VIDEOS{$video_id})) { + + $WATCHED_VIDEOS{$video_id} = 1; + + open my $fh, '>>', $CONFIG{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 play_video { my ($video) = @_; @@ -3111,7 +3550,13 @@ sub play_video { } my $code = execute_external_program($command); - warn "[!] Can't play this video -- player exited with code: $code\n" if $code != 0; + + if ($code == 0) { + save_watched_video($video_id, $video); + } + else { + warn "[!] Can't play this video -- player exited with code: $code\n"; + } return 1; } @@ -3133,32 +3578,16 @@ sub list_category { } } -sub list_tops { - - my $iter = $tops_treeview->get_selection->get_selected; - - my %top_opts; - $top_opts{feed_id} = $tops_liststore->get($iter, 2) // return; - my $top_type = $tops_liststore->get($iter, 3); +sub list_local_playlist { + my $iter = $playlists_treeview->get_selection->get_selected; - if ($top_type ne q{}) { - $top_opts{time_id} = $top_type; - } - - if (length(my $region = $gui->get_object('region_entry')->get_text)) { - $top_opts{region_id} = $region; - } - - if (length(my $category = $gui->get_object('category_entry')->get_text)) { - $top_opts{cat_id} = $category; - } + my $reverse_playlist = $gui->get_object('reverse_playlist')->get_active; + my $playlist_file = $playlists_liststore->get($iter, 3); $liststore->clear if $CONFIG{clear_search_list}; - display_results( - $top_type eq 'movies' - ? $yv_obj->get_movies($top_opts{feed_id}) - : $yv_obj->get_video_tops(%top_opts) - ); + + my $results = videos_from_data_file($playlist_file, reverse => $reverse_playlist); + display_results($results); } sub clear_text { @@ -3171,20 +3600,19 @@ sub clear_text { return 0; } -sub run_cli_fair_viewer { - execute_cli_fair_viewer('--interactive'); +sub run_cli_pipe_viewer { + execute_cli_pipe_viewer('--interactive'); } sub get_options_as_arguments { my @args; my %options = ( - 'no-interactive' => q{}, - 'resolution' => $CONFIG{resolution}, - 'download-dir' => quotemeta(rel2abs($CONFIG{downloads_dir})), - 'fullscreen' => $CONFIG{fullscreen} ? q{} : undef, - 'no-dash' => $CONFIG{dash_support} ? undef : q{}, - 'no-dash-segmented' => $CONFIG{dash_segmented} ? undef : q{}, - 'no-video' => $CONFIG{audio_only} ? q{} : undef, + 'no-interactive' => q{}, + 'resolution' => $CONFIG{resolution}, + 'download-dir' => quotemeta(rel2abs($CONFIG{downloads_dir})), + 'fullscreen' => $CONFIG{fullscreen} ? q{} : undef, + 'no-dash' => $CONFIG{dash} ? undef : q{}, + 'no-video' => $CONFIG{audio_only} ? q{} : undef, ); while (my ($argv, $value) = each %options) { @@ -3254,22 +3682,22 @@ sub enqueue_video { sub play_enqueued_videos { if (@VIDEO_QUEUE) { - execute_cli_fair_viewer('--video-ids=' . join(q{,}, splice @VIDEO_QUEUE)); + execute_cli_pipe_viewer('--video-ids=' . join(q{,}, splice @VIDEO_QUEUE)); } return 1; } -sub play_selected_video_with_cli_fair_viewer { +sub play_selected_video_with_cli_pipe_viewer { my ($code, $iter) = get_selected_entry_code(); $code // return; my $type = $liststore->get($iter, 7); if ($type eq 'video') { - execute_cli_fair_viewer("--video-id=$code"); + execute_cli_pipe_viewer("--video-id=$code"); } elsif ($type eq 'playlist') { - execute_cli_fair_viewer("--pp=$code"); + execute_cli_pipe_viewer("--pp=$code"); } else { warn "Can't play $type: $code\n"; @@ -3278,7 +3706,7 @@ sub play_selected_video_with_cli_fair_viewer { return 1; } -sub execute_cli_fair_viewer { +sub execute_cli_pipe_viewer { my @arguments = @_; my $command = join( @@ -3287,8 +3715,8 @@ sub execute_cli_fair_viewer { sprintf( $CONFIG{terminal_exec}, join(q{ }, - $CONFIG{fair_viewer}, get_options_as_arguments(), - @arguments, @{$CONFIG{fair_viewer_args}}), + $CONFIG{pipe_viewer}, get_options_as_arguments(), + @arguments, @{$CONFIG{pipe_viewer_args}}), ) ); my $code = execute_external_program($command); @@ -3301,7 +3729,7 @@ sub execute_cli_fair_viewer { sub download_video { my $code = get_selected_entry_code(type => 'video') // return; - execute_cli_fair_viewer("--video-id=$code", '--download'); + execute_cli_pipe_viewer("--video-id=$code", '--download'); return 1; } @@ -3336,7 +3764,7 @@ sub comments_row_activated { return 1; } -sub show_user_favorited_videos { +sub show_user_favorite_videos { my $username = get_channel_id_for_selected_video() // return; favorites('channel', $username); } @@ -3394,30 +3822,27 @@ sub display_comments { foreach my $comment (@{$comments}) { - #use Data::Dump qw(pp); - #pp $comment; - #my $comment_age = $yv_utils->date_to_age($snippet->{publishedAt}); my $comment_id = $yv_utils->get_comment_id($comment); my $comment_age = $yv_utils->get_publication_age_approx($comment); my $comment_text = reflow_text( - "<big><b>" - . encode_entities($yv_utils->get_author($comment)) - . "</b> (" - . ( - $comment_age =~ /sec|min|hour|day/ - ? "$comment_age ago" - : $yv_utils->get_publication_date($comment) - ) - . ") commented:</big>\n" - . encode_entities( + sprintf( + "<big><b>%s</b> (%s) commented:</big>\n%s", + encode_entities($yv_utils->get_author($comment)), + ( + $comment_age =~ /sec|min|hour|day/ + ? "$comment_age ago" + : ($yv_utils->get_publication_date($comment) // 'unknown') + ), + encode_entities( wrap_text( i_tab => "\t", s_tab => "\t", text => [$yv_utils->get_comment_content($comment) // 'Empty comment...'], ) - ) + ), + ) ); my $iter = $feeds_liststore->append; @@ -3497,7 +3922,6 @@ sub save_session { $ResultsHistory{current} = $#left + 1; $ResultsHistory{results} = [@left, $curr_result, @right]; - require Storable; Storable::store( { keyword => $search_entry->get_text, @@ -3559,30 +3983,89 @@ sub show_playlists_from_selected_author { } sub set_entry_details { - my ($code, $iter) = @_; + my ($code, $iter, %opt) = @_; - my $type = $liststore->get($iter, 7); - my $main_details = $liststore->get($iter, 0); - my $channel_id = get_channel_id_for_selected_video(); + my $type = $liststore->get($iter, 7); + my $info = $yv_obj->parse_json_string($liststore->get($iter, 8)); # Setting title - my $title = substr($main_details, 0, index($main_details, '</big>') + 6, ''); - $gui->get_object('video_title_label')->set_label("<big>$title</big>"); - $gui->get_object('video_title_label')->set_tooltip_markup("$title"); + my $title = $yv_utils->get_title($info); + + if ($type eq 'channel' or $type eq 'subscription') { + $title = $yv_utils->get_channel_title($info); + } + + $title = encode_entities($title); + + $gui->get_object('video_title_label')->set_label("<big><big><b>$title</b></big></big>"); + $gui->get_object('video_title_label')->set_tooltip_markup("<b>$title</b>"); + + my $text_info; - # Setting video details - $main_details =~ s/^\s+//; - $main_details =~ s{\s*<i>.+</i>\s*}{\n}; - $main_details =~ s{\h+}{ }g; - $main_details =~ s{^.*?<b>.*?</b>\K\h*}{\t}gm; + if ($type eq 'video') { - my $secondary_details = $liststore->get($iter, 2); - $secondary_details =~ s{\h+}{ }g; - $secondary_details =~ s{^.*?<b>.*?</b>\K\h*}{\t}gm; - $secondary_details .= "\n$symbols{black_face}\t$channel_id"; + if ($opt{extra_info}) { + my $extra_info = $yv_obj->video_details($yv_utils->get_video_id($info)); - my $text_info = join("\n", grep { !/^&#\w+;$/ } split(/\R/, "$main_details$secondary_details")); - $gui->get_object('video_details_label')->set_label($text_info); + foreach my $key (keys %$extra_info) { + $info->{$key} = $extra_info->{$key}; + } + } + + my $details_format = <<"EOT"; +$symbols{author}\t%s +$symbols{category}\t%s +$symbols{play}\t%s +$symbols{average}\t%s +$symbols{views}\t%s +$symbols{published}\t%s +$symbols{author_id}\t%s +EOT + + $text_info = sprintf($details_format, + $yv_utils->get_channel_title($info) // 'unknown', + $yv_utils->get_category_name($info) // 'unknown', + $yv_utils->get_time($info) // 'unknown', + $yv_utils->get_rating($info) // 'unknown', + $yv_utils->set_thousands($yv_utils->get_views($info) // 0), + $yv_utils->get_publication_date($info) // 'unknown', + $yv_utils->get_channel_id($info) // 'unknown', + ); + } + elsif ($type eq 'playlist') { + + my $details_format = <<"EOT"; +$symbols{author}\t%s +$symbols{video}\t%s videos +$symbols{author_id}\t%s +$symbols{play}\t%s +EOT + + $text_info = sprintf($details_format, + $yv_utils->get_channel_title($info) // 'unknown', + $yv_utils->set_thousands($yv_utils->get_playlist_video_count($info) // 0), + $yv_utils->get_channel_id($info) // 'unknown', + $yv_utils->get_playlist_id($info) // 'unknown', + ); + } + elsif ($type eq 'channel' or $type eq 'subscription') { + + my $details_format = <<"EOT"; +$symbols{author}\t%s +$symbols{video}\t%s videos +$symbols{subs}\t%s subscribers +$symbols{author_id}\t%s +EOT + + $text_info = sprintf($details_format, + $yv_utils->get_channel_title($info) // 'unknown', + $yv_utils->short_human_number($yv_utils->get_video_count($info) // 0), + $yv_utils->short_human_number($yv_utils->get_subscriber_count($info) // 0), + $yv_utils->get_channel_id($info) // 'unknown', + ); + } + + $gui->get_object('video_details_label')->set_label("<big>" . encode_entities("\n" . $text_info) . "</big>"); # Setting the link button my $url = make_youtube_url($type, $code); @@ -3591,8 +4074,6 @@ sub set_entry_details { $linkbutton->set_label($url); $linkbutton->set_uri($url); - my $info = $yv_obj->parse_json_string($liststore->get($iter, 8)); - my %thumbs = ( start => 1, middle => 2, @@ -3600,15 +4081,15 @@ sub set_entry_details { ); # Getting thumbs - foreach my $type (keys %thumbs) { + foreach my $nr (keys %thumbs) { - $gui->get_object("image$thumbs{$type}")->set_from_pixbuf($default_thumb); + $gui->get_object("image$thumbs{$nr}")->set_from_pixbuf($default_thumb); Glib::Idle->add( sub { - my ($type) = @{$_[0]}; + my ($nr) = @{$_[0]}; - my $url = $yv_utils->get_thumbnail_url($info, $type); + my $url = $yv_utils->get_thumbnail_url($info, $nr); #~ my $thumbnail = $info->{snippet}{thumbnails}{medium}; #~ my $url = $thumbnail->{url}; @@ -3617,21 +4098,32 @@ sub set_entry_details { ## no extra thumbnails available while video is LIVE } else { - $url =~ s{/\w+\.(\w+)\z}{/mq$thumbs{$type}.$1}; + $url =~ s{/\w+\.(\w+)\z}{/mq$thumbs{$nr}.$1}; + } + + my ($size_x, $size_y) = (160, 90); + + if ($type eq 'subscription' or $type eq 'channel') { + ($size_x, $size_y) = (120, 120); } - my $pixbuf = get_pixbuf_thumbnail_from_url($url, 160, 90); - $gui->get_object("image$thumbs{$type}")->set_from_pixbuf($pixbuf); + my $pixbuf = get_pixbuf_thumbnail_from_url($url, $size_x, $size_y); + $gui->get_object("image$thumbs{$nr}")->set_from_pixbuf($pixbuf); return 0; }, - [$type], - Glib::G_PRIORITY_DEFAULT_IDLE + [$nr], + Glib::G_PRIORITY_LOW ); } # Setting textview description - set_text($gui->get_object('description_textview'), decode_entities($liststore->get($iter, 4))); + set_text($gui->get_object('description_textview'), $yv_utils->get_description($info)); + + if ($type eq 'video' and not $opt{extra_info}) { + Glib::Idle->add(sub { set_entry_details($code, $iter, extra_info => 1); return 0 }, [], Glib::G_PRIORITY_LOW); + } + return 1; } @@ -3652,7 +4144,6 @@ $notebook->set_current_page($CONFIG{default_notebook_page}); if ($CONFIG{remember_session} and -f $session_file) { - require Storable; my $session = eval { Storable::retrieve($session_file) }; if (ref($session) eq 'HASH') { @@ -3668,7 +4159,7 @@ if ($CONFIG{remember_session} and -f $session_file) { return 0; }, [], - Glib::G_PRIORITY_DEFAULT_IDLE + Glib::G_PRIORITY_LOW ); } } @@ -3682,15 +4173,7 @@ if (@ARGV) { my $text = join(' ', @ARGV); $search_entry->set_text($text); $search_entry->set_position(length($text)); - - Glib::Idle->add( - sub { - search(); - return 0; - }, - [], - Glib::G_PRIORITY_DEFAULT_IDLE - ); + Glib::Idle->add(sub { search(); return 0 }, [], Glib::G_PRIORITY_LOW); } 'Gtk3'->main; |