From 739c821a54c01816e60eb5f774c8977a1e221ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs?= Date: Fri, 9 Jul 2021 15:27:16 -0500 Subject: upstream --- .gitignore | 5 +- Build.PL | 14 +- MANIFEST | 1 + META.json | 124 -- META.yml | 85 -- Makefile | 30 - README.md | 48 +- bin/fair-viewer | 2060 ++++++++++++++++++++------------- bin/gtk-fair-viewer | 1509 ++++++++++++++++-------- lib/WWW/FairViewer.pm | 393 +++++-- lib/WWW/FairViewer/Channels.pm | 91 +- lib/WWW/FairViewer/CommentThreads.pm | 1 + lib/WWW/FairViewer/GetCaption.pm | 16 +- lib/WWW/FairViewer/GuideCategories.pm | 1 + lib/WWW/FairViewer/InitialData.pm | 1050 +++++++++++++++++ lib/WWW/FairViewer/Itags.pm | 207 ++-- lib/WWW/FairViewer/ParseJSON.pm | 12 + lib/WWW/FairViewer/ParseXML.pm | 3 + lib/WWW/FairViewer/PlaylistItems.pm | 8 +- lib/WWW/FairViewer/Playlists.pm | 12 +- lib/WWW/FairViewer/Search.pm | 25 +- lib/WWW/FairViewer/Utils.pm | 351 +++++- lib/WWW/FairViewer/Videos.pm | 105 +- share/gtk-fair-viewer.glade | 318 +++-- utils/auto_perltidy.sh | 2 +- utils/bak_cleaner.sh | 2 +- 26 files changed, 4401 insertions(+), 2072 deletions(-) delete mode 100644 META.json delete mode 100644 META.yml delete mode 100644 Makefile create mode 100644 lib/WWW/FairViewer/InitialData.pm diff --git a/.gitignore b/.gitignore index 5413df8..f9e43d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ !Build/ .last_cover_stats -#/META.yml -#/META.json +/META.yml +/META.json /MYMETA.* *.o *.pm.tdy @@ -28,6 +28,7 @@ inc/ /blib/ /_eumm/ /*.gz +/Makefile /Makefile.old /MANIFEST.bak /pm_to_blib diff --git a/Build.PL b/Build.PL index 05c9410..45582fe 100644 --- a/Build.PL +++ b/Build.PL @@ -64,22 +64,12 @@ my $builder = Module::Build->new( }, recommends => { - 'LWP::UserAgent::Cached' => 0, # cache support + 'LWP::UserAgent::Cached' => 0, # local cache support 'Term::ReadLine::Gnu' => 0, # for better STDIN support (+history) 'JSON::XS' => 0, # faster JSON to HASH conversion - 'Mozilla::CA' => 0, # just in case if there are SSL problems + 'Unicode::GCString' => 0, # fixed-width formatting }, - auto_features => { - fixed_width_support => { - description => "Print the results in a fixed-width format (--fixed-width, -W)", - requires => { - 'Unicode::GCString' => 0, # this is recommended - #'Text::CharWidth' => 0, # this works as fallback - }, - }, - }, - add_to_cleanup => ['WWW-FairViewer-*'], create_makefile_pl => 'traditional', ); diff --git a/MANIFEST b/MANIFEST index dc2db63..e0720a3 100644 --- a/MANIFEST +++ b/MANIFEST @@ -9,6 +9,7 @@ lib/WWW/FairViewer/Channels.pm lib/WWW/FairViewer/CommentThreads.pm lib/WWW/FairViewer/GetCaption.pm lib/WWW/FairViewer/GuideCategories.pm +lib/WWW/FairViewer/InitialData.pm lib/WWW/FairViewer/Itags.pm lib/WWW/FairViewer/ParseJSON.pm lib/WWW/FairViewer/ParseXML.pm diff --git a/META.json b/META.json deleted file mode 100644 index d6df1b9..0000000 --- a/META.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "abstract" : "A lightweight application for searching and streaming videos from YouTube, using the Invidious API and hypervideo support.", - "author" : [ - "Trizen .", - "Jesus E. " - ], - "dynamic_config" : 1, - "generated_by" : "Module::Build version 0.4231", - "license" : [ - "perl_5" - ], - "meta-spec" : { - "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec", - "version" : 2 - }, - "name" : "WWW-FairViewer", - "prereqs" : { - "build" : { - "requires" : { - "Test::More" : "0" - } - }, - "configure" : { - "requires" : { - "Module::Build" : "0" - } - }, - "runtime" : { - "recommends" : { - "JSON::XS" : "0", - "LWP::UserAgent::Cached" : "0", - "Mozilla::CA" : "0", - "Term::ReadLine::Gnu" : "0" - }, - "requires" : { - "Data::Dump" : "0", - "Encode" : "0", - "File::Path" : "0", - "File::Spec" : "0", - "File::Spec::Functions" : "0", - "Getopt::Long" : "0", - "HTTP::Request" : "0", - "JSON" : "0", - "LWP::Protocol::https" : "0", - "LWP::UserAgent" : "0", - "List::Util" : "0", - "MIME::Base64" : "0", - "Memoize" : "0", - "Term::ANSIColor" : "0", - "Term::ReadLine" : "0", - "Text::ParseWords" : "0", - "Text::Wrap" : "0", - "URI::Escape" : "0", - "perl" : "5.016" - } - } - }, - "provides" : { - "WWW::FairViewer" : { - "file" : "lib/WWW/FairViewer.pm", - "version" : "v1.0.6" - }, - "WWW::FairViewer::Activities" : { - "file" : "lib/WWW/FairViewer/Activities.pm" - }, - "WWW::FairViewer::Authentication" : { - "file" : "lib/WWW/FairViewer/Authentication.pm" - }, - "WWW::FairViewer::Channels" : { - "file" : "lib/WWW/FairViewer/Channels.pm" - }, - "WWW::FairViewer::CommentThreads" : { - "file" : "lib/WWW/FairViewer/CommentThreads.pm" - }, - "WWW::FairViewer::GetCaption" : { - "file" : "lib/WWW/FairViewer/GetCaption.pm" - }, - "WWW::FairViewer::GuideCategories" : { - "file" : "lib/WWW/FairViewer/GuideCategories.pm" - }, - "WWW::FairViewer::Itags" : { - "file" : "lib/WWW/FairViewer/Itags.pm" - }, - "WWW::FairViewer::ParseJSON" : { - "file" : "lib/WWW/FairViewer/ParseJSON.pm" - }, - "WWW::FairViewer::ParseXML" : { - "file" : "lib/WWW/FairViewer/ParseXML.pm" - }, - "WWW::FairViewer::PlaylistItems" : { - "file" : "lib/WWW/FairViewer/PlaylistItems.pm" - }, - "WWW::FairViewer::Playlists" : { - "file" : "lib/WWW/FairViewer/Playlists.pm" - }, - "WWW::FairViewer::RegularExpressions" : { - "file" : "lib/WWW/FairViewer/RegularExpressions.pm" - }, - "WWW::FairViewer::Search" : { - "file" : "lib/WWW/FairViewer/Search.pm" - }, - "WWW::FairViewer::Subscriptions" : { - "file" : "lib/WWW/FairViewer/Subscriptions.pm" - }, - "WWW::FairViewer::Utils" : { - "file" : "lib/WWW/FairViewer/Utils.pm" - }, - "WWW::FairViewer::VideoCategories" : { - "file" : "lib/WWW/FairViewer/VideoCategories.pm" - }, - "WWW::FairViewer::Videos" : { - "file" : "lib/WWW/FairViewer/Videos.pm" - } - }, - "release_status" : "stable", - "resources" : { - "license" : [ - "http://dev.perl.org/licenses/", - "https://www.gnu.org/licenses/gpl-3.0.html" - ] - }, - "version" : "v1.0.6", - "x_serialization_backend" : "JSON::PP version 4.04" -} diff --git a/META.yml b/META.yml deleted file mode 100644 index 103e8d0..0000000 --- a/META.yml +++ /dev/null @@ -1,85 +0,0 @@ ---- -abstract: 'A lightweight application for searching and streaming videos from YouTube, using the Invidious API and hypervideo support.' -author: - - 'Trizen .' - - 'Jesus E. ' -build_requires: - Test::More: '0' -configure_requires: - Module::Build: '0' -dynamic_config: 1 -generated_by: 'Module::Build version 0.4231, CPAN::Meta::Converter version 2.150010' -license: perl -meta-spec: - url: http://module-build.sourceforge.net/META-spec-v1.4.html - version: '1.4' -name: WWW-FairViewer -provides: - WWW::FairViewer: - file: lib/WWW/FairViewer.pm - version: v1.0.6 - WWW::FairViewer::Activities: - file: lib/WWW/FairViewer/Activities.pm - WWW::FairViewer::Authentication: - file: lib/WWW/FairViewer/Authentication.pm - WWW::FairViewer::Channels: - file: lib/WWW/FairViewer/Channels.pm - WWW::FairViewer::CommentThreads: - file: lib/WWW/FairViewer/CommentThreads.pm - WWW::FairViewer::GetCaption: - file: lib/WWW/FairViewer/GetCaption.pm - WWW::FairViewer::GuideCategories: - file: lib/WWW/FairViewer/GuideCategories.pm - WWW::FairViewer::Itags: - file: lib/WWW/FairViewer/Itags.pm - WWW::FairViewer::ParseJSON: - file: lib/WWW/FairViewer/ParseJSON.pm - WWW::FairViewer::ParseXML: - file: lib/WWW/FairViewer/ParseXML.pm - WWW::FairViewer::PlaylistItems: - file: lib/WWW/FairViewer/PlaylistItems.pm - WWW::FairViewer::Playlists: - file: lib/WWW/FairViewer/Playlists.pm - WWW::FairViewer::RegularExpressions: - file: lib/WWW/FairViewer/RegularExpressions.pm - WWW::FairViewer::Search: - file: lib/WWW/FairViewer/Search.pm - WWW::FairViewer::Subscriptions: - file: lib/WWW/FairViewer/Subscriptions.pm - WWW::FairViewer::Utils: - file: lib/WWW/FairViewer/Utils.pm - WWW::FairViewer::VideoCategories: - file: lib/WWW/FairViewer/VideoCategories.pm - WWW::FairViewer::Videos: - file: lib/WWW/FairViewer/Videos.pm -recommends: - JSON::XS: '0' - LWP::UserAgent::Cached: '0' - Mozilla::CA: '0' - Term::ReadLine::Gnu: '0' -requires: - Data::Dump: '0' - Encode: '0' - File::Path: '0' - File::Spec: '0' - File::Spec::Functions: '0' - Getopt::Long: '0' - HTTP::Request: '0' - JSON: '0' - LWP::Protocol::https: '0' - LWP::UserAgent: '0' - List::Util: '0' - MIME::Base64: '0' - Memoize: '0' - Term::ANSIColor: '0' - Term::ReadLine: '0' - Text::ParseWords: '0' - Text::Wrap: '0' - URI::Escape: '0' - perl: '5.016' -resources: - license: - - http://dev.perl.org/licenses/ - - https://www.gnu.org/licenses/gpl-3.0.html -version: v1.0.6 -x_serialization_backend: 'CPAN::Meta::YAML version 0.018' diff --git a/Makefile b/Makefile deleted file mode 100644 index 551e0c3..0000000 --- a/Makefile +++ /dev/null @@ -1,30 +0,0 @@ -all: clean update generate - -O_VERSION=1.0.5 -C_VERSION=1.0.6 - -clean: - rm -rf _build/ Build fair-viewer-*.tar.gz MYMETA.{yml,json} - find . -name "*.bs" -delete - find . -name "*.o" -delete - find . -name "*.pm.tdy" -delete - -_ROOT_FILES = $(shell find . -type f \( -iname 'META.json' -o -iname 'META.yml' \)) -_BIN_FILES = $(shell find ./bin -type f \( -iname 'gtk-fair-viewer' -o -iname 'fair-viewer' \)) -_LIB_FILES = $(shell find ./lib/WWW -type f -iname 'FairViewer.pm') - -update: - @sed -i "s|v$(O_VERSION)|v$(C_VERSION)|g" $(_ROOT_FILES) $(_BIN_FILES) - @sed -i "s|$(O_VERSION)|$(C_VERSION)|g" $(_LIB_FILES) - -generate: - @tar -czf fair-viewer-$(C_VERSION).tar.gz --transform "s|^|fair-viewer-$(C_VERSION)/|" --owner 0 --group 0 \ - --exclude '*.bs' \ - --exclude '*.o' \ - --exclude '*.pm.tdy' \ - --exclude '.git' \ - -- \ - bin lib share t \ - Changes LICENSE README.md Artistic-2.0.txt \ - Build.PL Makefile.PL MANIFEST MANIFEST.SKIP \ - META.json META.yml diff --git a/README.md b/README.md index 3f1f21e..3ff3774 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ ## fair-viewer -A lightweight application (fork of [youtube-viewer](https://github.com/trizen/youtube-viewer)) for searching and playing videos from YouTube, using the [API](https://github.com/iv-org/invidious/wiki/API) of [invidio.us](https://invidio.us/) and [hypervideo](https://framagit.org/heckyel/hypervideo) support. +A lightweight application (fork of [youtube-viewer](https://github.com/trizen/youtube-viewer)) +for searching and streaming videos from YouTube, using the Invidious API and +[hypervideo](https://git.conocimientoslibres.ga/software/hypervideo.git) support. + +This fork parses the YouTube website directly and relies on the invidious instances only as a fallback method. ### fair-viewer @@ -12,11 +16,15 @@ A lightweight application (fork of [youtube-viewer](https://github.com/trizen/yo * GTK+ interface to YouTube. -![gtk-fair-viewer](https://user-images.githubusercontent.com/614513/73087694-93ffdb80-3edb-11ea-8fea-05901d72f68d.png) +![gtk-fair-viewer](https://user-images.githubusercontent.com/614513/97737137-89125100-1ad4-11eb-8ff3-b19cd0041528.png) + +### VIDEO REVIEWS -### STATUS +* [EN] fair-viewer and YouTube-Viewer -- Search Youtube via Terminal - Linux CLI + * https://www.youtube.com/watch?v=I4tfHUmklWo -The project is in its early stages of development and some features are not implemented yet. +* [TW] fair-viewer!有史以來最佳的 YouTube 體驗就在這裡~ + * https://wiwi.video/videos/watch/798d38cd-9d10-4f8a-ac1f-f776c6d0aa2c ### TRY @@ -24,10 +32,9 @@ For trying the latest commit of `fair-viewer`, without installing it, execute th ```console cd /tmp - wget https://libregit.org/heckyel/fair-viewer/archive/master.zip -O fair-viewer-master.zip - unzip -n fair-viewer-master.zip - cd fair-viewer-master/bin - perl -pi -ne 's{DEVEL = 0}{DEVEL = 1}' {gtk-,}fair-viewer + wget http://git.conocimientoslibres.ga/software/fair-viewer.git/snapshot/fair-viewer-1.0.6.zip -O fair-viewer-1.0.6.zip + unzip -n fair-viewer-main.zip + cd fair-viewer-main/bin ./fair-viewer ``` @@ -85,9 +92,9 @@ for `gtk-fair-viewer`, run: #### Optional dependencies: * Local cache support: [LWP::UserAgent::Cached](https://metacpan.org/release/LWP-UserAgent-Cached) -* Better STDIN support (+ history): [Term::ReadLine::Gnu](https://metacpan.org/release/Term-ReadLine-Gnu) +* Better STDIN support (+history): [Term::ReadLine::Gnu](https://metacpan.org/release/Term-ReadLine-Gnu) * Faster JSON deserialization: [JSON::XS](https://metacpan.org/release/JSON-XS) -* Fixed-width formatting (--fixed-width, -W): [Unicode::LineBreak](https://metacpan.org/release/Unicode-LineBreak) or [Text::CharWidth](https://metacpan.org/release/Text-CharWidth) +* Fixed-width formatting: [Unicode::LineBreak](https://metacpan.org/release/Unicode-LineBreak) or [Text::CharWidth](https://metacpan.org/release/Text-CharWidth) ### PACKAGING @@ -102,39 +109,32 @@ To package this application, run the following commands: ### INVIDIOUS INSTANCES -Sometimes, the default instance, [invidious.snopyta.org](https://invidious.snopyta.org/), may fail to work properly. When this happens, we can change the API host to some other instance of invidious, such as [invidious.tube](https://invidious.tube/): +To use a specific invidious instance, like [invidious.snopyta.org](https://invidious.snopyta.org/), we have (see also the [--invidious](https://github.com/trizen/fair-viewer/commit/17fb2136f3f3d8ee6dacac05beabcc15082f699d) option): ```console - fair-viewer --api=invidious.tube + fair-viewer --api=invidious.snopyta.org ``` To make the change permanent, set in the configuration file: ```perl - api_host => "invidious.tube", + api_host => "invidious.snopyta.org", ``` -Alternatively, the following will automatically pick a random invidious instance everytime the program is started: - -```perl - api_host => "auto", -``` - -The available instances are listed at: https://instances.invidio.us/ - -### PIPE-VIEWER -[pipe-viewer](https://github.com/trizen/pipe-viewer) is an experimental fork of `straw-viewer` with the goal of parsing the YouTube website directly, and thus it may be a faster and more reliable alternative. +By default, `fair-viewer` picks a random invidious instance from [api.invidious.io](https://api.invidious.io/) on-demand. ### SUPPORT AND DOCUMENTATION After installing, you can find documentation with the following commands: +```console man fair-viewer perldoc WWW::FairViewer +``` ### LICENSE AND COPYRIGHT -Copyright (C) 2012-2020 Trizen +Copyright (C) 2012-2021 Trizen Copyright (C) 2020 Jesus 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 . -# Copyright (C) 2020 Jesus E. . +# Copyright (C) 2010-2021 Trizen . +# Copyright (C) 2020-2021 Jesus E. . + # # This program is free software; you can redistribute it and/or modify it # under the terms of either: the GNU General Public License as published @@ -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 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 + : 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 + : 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: , :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(); - - foreach my $id (@videoIDs) { - my $videoID = get_valid_video_id($id) // next; + my (@videos) = @_; - 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 { - my $gcstr = Unicode::GCString->new($str); - my $str_width = $gcstr->columns; + # Unicode::GCString + if ($pkg eq 'Unicode::GCString') { + + my $gcstr = Unicode::GCString->new($str); + my $str_width = $gcstr->columns; - if ($str_width != $len) { while ($str_width > $len) { $gcstr = $gcstr->substr(0, -1); $str_width = $gcstr->columns; } $str = $gcstr->as_string; - my $spaces = ' ' x ($len - $str_width); - $str = $prepend ? "$spaces$str" : "$str$spaces"; + return ($str, $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"; + } + + 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"); } - return $str; + 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 $entry = $opt{custom_channel_layout_format}; - my $channel = $channels->[$i]; - my $title = clear_title($yv_utils->get_channel_title($channel)); + push @formatted, format_line_result($i, $entry, $channel); + } - printf "%s. %s %s [%*s]\n", colored(sprintf('%2d', $i + 1), 'bold'), - adjust_width($title, $title_length), - adjust_width($authors[$i], $author_width, 1), - $dates_width, $dates[$i]; - } - last; - } - else { - print "\n" if $i == 0; - printf "%s. %s [%s]\n", colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_channel_title($channel), - $yv_utils->get_publication_date($channel); - } + if (@formatted) { + print "\n" . join("", @formatted); } my @keywords = get_input_for_channels(); @@ -2542,41 +2520,150 @@ 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; } - else { - warn_invalid('option', $opt); + + # :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'); + } } - } - elsif (youtube_urls($key)) { - ## ok - } - elsif (valid_num($key, $channels)) { - print_videos($yv_obj->uploads($yv_utils->get_channel_id($channels->[$key - 1]))); - } - else { - push @for_search, $key; - } - } - if (@for_search) { - __SUB__->($yv_obj->search_channels(\@for_search)); - } + # :pv=i, :popular=i + elsif ($opt =~ /^(?:pv|popular)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$channels}, $1)) { - __SUB__->(@_); -} + foreach my $id (@nums) { -sub print_comments { - my ($results, $videoID) = @_; + my $channel_id = $yv_utils->get_channel_id($channels->[$id]); + my $request = $yv_obj->popular_videos($channel_id); - if (not $yv_utils->has_entries($results)) { - warn_no_results("comments"); - } + if ($yv_utils->has_entries($request)) { + print_videos($request); + } + else { + warn_no_results('popular video'); + } + } + } + else { + warn_no_thing_selected('channel'); + } + } + + # :p=i, :playlist=i, :up=i + elsif ($opt =~ /^(?:p|l|playlists?|up)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$channels}, $1)) { + + foreach my $id (@nums) { + + my $channel_id = $yv_utils->get_channel_id($channels->[$id]); + my $request = $yv_obj->playlists($channel_id); + + if ($yv_utils->has_entries($request)) { + print_playlists($request); + } + else { + warn_no_results('playlist'); + } + } + } + else { + warn_no_thing_selected('channel'); + } + } + + # :s=i, :subscribe=i + elsif ($opt =~ /^(?:s|sub(?:scribe)?)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$channels}, $1)) { + foreach my $id (@nums) { + my $channel_id = $yv_utils->get_channel_id($channels->[$id]); + my $channel_title = $yv_utils->get_channel_title($channels->[$id]); + subscribe_channel($channel_id, $channel_title); + } + } + else { + warn_no_thing_selected('channel'); + } + } + + # :save=i + elsif ($opt =~ /^(?:save)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$channels}, $1)) { + foreach my $id (@nums) { + my $channel_id = $yv_utils->get_channel_id($channels->[$id]); + my $channel_title = $yv_utils->get_channel_title($channels->[$id]); + save_channel($channel_id, $channel_title); + } + } + else { + warn_no_thing_selected('channel'); + } + } + + # :r=i, :rm=i, :remove=i, :unsubscribe=i + elsif ($opt =~ /^(?:r|rm|remove)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$channels}, $1)) { + remove_saved_channels(map { $yv_utils->get_channel_id($channels->[$_]) } @nums); + } + else { + warn_no_thing_selected('channel'); + } + } + + # :unsub=i + elsif ($opt =~ /^(?:unsub(?:scribe)?)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$channels}, $1)) { + unsubscribe_from_channels(map { $yv_utils->get_channel_id($channels->[$_]) } @nums); + } + else { + warn_no_thing_selected('channel'); + } + } + else { + warn_invalid('option', $opt); + } + } + elsif (youtube_urls($key)) { + ## ok + } + elsif (valid_num($key, $channels)) { + print_videos($yv_obj->uploads($yv_utils->get_channel_id($channels->[$key - 1]))); + } + else { + push @for_search, $key; + } + } + + if (@for_search) { + __SUB__->($yv_obj->search_channels(\@for_search)); + } + + __SUB__->(@_); +} + +sub print_comments { + my ($results, $videoID) = @_; + + if (not $yv_utils->has_entries($results)) { + warn_no_results("comments"); + } my $url = $results->{url}; my $comments = $results->{results}{comments} // []; @@ -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 (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 ($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; + 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; + + if (ref($results->{results}) eq 'HASH' and exists $results->{results}{continuation}) { + $token = $results->{results}{continuation}; + } - say "\n:: Probably $current_instance is down. Try:"; + 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'); + } + } + + # :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'); } } - elsif ($opt =~ /^(?:A|[Aa]ctivity)${digit_or_equal_re}(.*)/) { + + # :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 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 and C are both enabled, there is no need to enable this option. -=head2 custom_layout - -Use a custom layout for video results, defined in C. - -Requires: L or L. - =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 are listed in: fair-viewer --tricks -=head2 dash_mp4_audio +For better formatting, it's highly recommended to install L or L. -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 can be used. +Any color name supported by L 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. @@ -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 or L. - -=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 -Order of subscriptions. Currently, not implemented. +Absolute path to the file where to store subscribed channels (C<:sub=i>). + +=head2 subscriptions_limit + +Maximum number of subscription videos to store in the local database. Set to C<0> for no limit. =head2 thousand_separator @@ -4457,7 +4901,7 @@ The available special tokens are listed in: =head2 video_player_selected -The selected video player defined the C table. +The selected video player defined in the C 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 license in search results. +=head2 watch_history + +Remember watched videos. Watched videos can be listed with: + + fair-viewer -wv + =head2 watched_file -File where to save the video IDs of watched/downloaded videos when C is set to a true value. +File where to save the video IDs of watched/downloaded videos when C is set to a true value. =head2 wget_cmd Command for C when C is set to a true value. +=head2 youtube_users_file + +Absolute path to the file where to store saved channels (C<:save=i>). + =head2 youtube_video_url Format for C for constructing an YouTube video URL given the video ID. @@ -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 . -# Copyright (C) 2020 Jesus E. . +# Copyright (C) 2010-2021 Trizen . +# Copyright (C) 2020-2021 Jesus E. . # # This program is free software; you can redistribute it and/or modify it # under the terms of either: the GNU General Public License as published @@ -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, "\t$main_label"); - #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 = '' . ('=' x 20) . ''; @@ -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 => "LOAD MORE", 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 { '' . encode_entities($title) . "\n\n" - . "$symbols{face}\t " + . "$symbols{author}\t " . encode_entities($channel_id) . "\n" - . "$symbols{crazy_arrow}\t " - . $yv_utils->get_publication_date($subscription) + . "$symbols{published}\t " + . ($yv_utils->get_publication_date($subscription) // 'unknown') . "\n\n" . encode_entities($row_description) . ''; - my $type_label = "$symbols{diamond} " . '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( "" - . encode_entities($title) - . "\n" - . "$symbols{up_arrow}\t " - . $yv_utils->set_thousands($yv_utils->get_likes($video)) . "\n" - . "$symbols{down_arrow}\t " - . $yv_utils->set_thousands($yv_utils->get_dislikes($video)) . "\n" - . "$symbols{ellipsis}\t " - . encode_entities($yv_utils->get_category_name($video)) . "\n" - . "$symbols{face}\t " - . encode_entities($yv_utils->get_channel_title($video)) . "\n" . "" - . encode_entities($row_description) - . ""); - - my $info_label = - reflow_text( "$symbols{play}\t " - . $yv_utils->get_time($video) . "\n" - . "$symbols{diamond}\t " - . $yv_utils->get_definition($video) . "\n" - . "$symbols{views}\t " - . $yv_utils->set_thousands($yv_utils->get_views($video)) . "\n" - . "$symbols{right_arrow}\t " - . $yv_utils->get_publication_date($video)); + my $title_label = reflow_text( + sprintf( + "%s + +$symbols{author}\t %s +$symbols{published}\t %s + +%s", + + 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( '' - . encode_entities($title) - . "\n\n" - . "$symbols{face}\t " - . encode_entities($yv_utils->get_channel_title($channel)) . "\n" - . "$symbols{play}\t " - . encode_entities($channel_id) . "\n" - . "$symbols{crazy_arrow}\t " - . $yv_utils->get_publication_date($channel) - . "\n\n" - . encode_entities($row_description) - . ''); - - my $type_label = reflow_text("$symbols{diamond} " . 'Channel' . "\n"); + my $title_label = reflow_text( + sprintf( + "%s + +$symbols{author}\t %s +$symbols{author_id}\t %s + +%s", + 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( '' - . encode_entities($title) - . "\n\n" - . "$symbols{face}\t " - . encode_entities($channel_title) . "\n" - . "$symbols{play}\t " - . encode_entities($playlist_id) . "\n" - . "$symbols{crazy_arrow}\t " - . $yv_utils->get_publication_date($playlist) . "\n\n" . '' - . encode_entities($row_description) - . ''); - - my $num_items_template = "$symbols{numero} %d items\n"; - my $num_items_text = sprintf($num_items_template, $yv_utils->get_playlist_video_count($playlist)); - - my $type_label = reflow_text("$symbols{diamond} " . 'Playlist' . "\n" . $num_items_text); + my $title_label = reflow_text( + sprintf( + "%s + +$symbols{author}\t %s +$symbols{play}\t %s + +%s", + + 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( - "" - . encode_entities($yv_utils->get_author($comment)) - . " (" - . ( - $comment_age =~ /sec|min|hour|day/ - ? "$comment_age ago" - : $yv_utils->get_publication_date($comment) - ) - . ") commented:\n" - . encode_entities( + sprintf( + "%s (%s) commented:\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, '') + 6, ''); - $gui->get_object('video_title_label')->set_label("$title"); - $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("$title"); + $gui->get_object('video_title_label')->set_tooltip_markup("$title"); + + my $text_info; - # Setting video details - $main_details =~ s/^\s+//; - $main_details =~ s{\s*.+\s*}{\n}; - $main_details =~ s{\h+}{ }g; - $main_details =~ s{^.*?.*?\K\h*}{\t}gm; + if ($type eq 'video') { - my $secondary_details = $liststore->get($iter, 2); - $secondary_details =~ s{\h+}{ }g; - $secondary_details =~ s{^.*?.*?\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("" . encode_entities("\n" . $text_info) . ""); # 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; diff --git a/lib/WWW/FairViewer.pm b/lib/WWW/FairViewer.pm index a192396..dff63f4 100644 --- a/lib/WWW/FairViewer.pm +++ b/lib/WWW/FairViewer.pm @@ -6,12 +6,14 @@ use warnings; use Memoize; -memoize('_get_video_info'); +#memoize('_get_video_info'); memoize('_ytdl_is_available'); +memoize('_info_from_ytdl'); memoize('_extract_from_ytdl'); memoize('_extract_from_invidious'); use parent qw( + WWW::FairViewer::InitialData WWW::FairViewer::Search WWW::FairViewer::Videos WWW::FairViewer::Channels @@ -23,11 +25,11 @@ use parent qw( WWW::FairViewer::CommentThreads WWW::FairViewer::Authentication WWW::FairViewer::VideoCategories - ); +); =head1 NAME -WWW::FairViewer - A very easy interface to YouTube, using the API of invidio.us. +WWW::FairViewer - A very easy interface to YouTube, using the API of invidious. =cut @@ -79,10 +81,11 @@ my %valid_options = ( ytdl_cmd => {valid => qr/\w/, default => "hypervideo"}, # Booleans - env_proxy => {valid => [1, 0], default => 1}, - escape_utf8 => {valid => [1, 0], default => 0}, - prefer_mp4 => {valid => [1, 0], default => 0}, - prefer_av1 => {valid => [1, 0], default => 0}, + env_proxy => {valid => [1, 0], default => 1}, + escape_utf8 => {valid => [1, 0], default => 0}, + prefer_mp4 => {valid => [1, 0], default => 0}, + prefer_av1 => {valid => [1, 0], default => 0}, + prefer_invidious => {valid => [1, 0], default => 0}, # API/OAuth key => {valid => qr/^.{15}/, default => undef}, @@ -95,16 +98,21 @@ my %valid_options = ( authentication_file => {valid => qr/^./, default => undef}, api_host => {valid => qr/\w/, default => "auto"}, +#<<< # No input value allowed api_path => {valid => q[], default => '/api/v1/'}, video_info_url => {valid => q[], default => 'https://www.youtube.com/get_video_info'}, oauth_url => {valid => q[], default => 'https://accounts.google.com/o/oauth2/'}, - video_info_args => {valid => q[], default => '?video_id=%s&el=detailpage&ps=default&eurl=&gl=US&hl=en'}, + video_info_args => {valid => q[], default => '?video_id=%s&el=detailpage&ps=default&eurl=&gl=US&hl=en&html5=1&c=TVHTML5&cver=6.20180913'}, www_content_type => {valid => q[], default => 'application/x-www-form-urlencoded'}, + m_youtube_url => {valid => q[], default => 'https://m.youtube.com'}, + youtubei_url => {valid => q[], default => 'https://youtubei.googleapis.com/youtubei/v1/%s?key=' . reverse("8Wcq11_9Y_wliCGLHETS4Q8UqlS2JF_OAySazIA")}, +#>>> #<<< # LWP user agent - user_agent => {valid => qr/^.{5}/, default => 'Mozilla/5.0 (Windows NT 10.0; Win64; gzip; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.0.0 Safari/537.36'}, + #user_agent => {valid => qr/^.{5}/, default => 'Mozilla/5.0 (iPad; CPU OS 7_1_1 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D201 Safari/9537.53'}, + user_agent => {valid => qr/^.{5}/, default => 'Mozilla/5.0 (Android 11; Tablet; rv:83.0) Gecko/83.0 Firefox/83.0,gzip(gfe)'}, #>>> ); @@ -297,7 +305,7 @@ sub set_lwp_useragent { require LWP::ConnCache; state $cache = LWP::ConnCache->new; - $cache->total_capacity(undef); # no limit + $cache->total_capacity(undef); # no limit state $accepted_encodings = do { require HTTP::Message; @@ -319,14 +327,11 @@ sub set_lwp_useragent { ## Netscape HTTP Cookies - # Chrome extension: - # https://chrome.google.com/webstore/detail/cookiestxt/njabckikapfpffapmjgojcnbfjonfjfg - # Firefox extension: # https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/ # 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 require HTTP::Cookies::Netscape; @@ -339,6 +344,19 @@ sub set_lwp_useragent { $cookies->load; $agent->cookie_jar($cookies); } + else { + + require HTTP::Cookies; + + my $cookies = HTTP::Cookies->new(); + + # Consent cookie + $cookies->set_cookie(0, "CONSENT", "YES+cb-m.20210615-14-p0.en+FX+096", + "/", ".youtube.com", undef, 0, 1, '21' . join('', map { int(rand(10)) } 1 .. 8), + 0, {}); + + $agent->cookie_jar($cookies); + } push @{$agent->requests_redirectable}, 'POST'; $self->{lwp} = $agent; @@ -396,56 +414,57 @@ sub lwp_get { $url // return; $self->{lwp} // $self->set_lwp_useragent(); - my %lwp_header = ($opt{simple} ? () : $self->_auth_lwp_header); - my $response = $self->{lwp}->get($url, %lwp_header); + if ($url =~ m{^//}) { + $url = 'https:' . $url; + } - if ($response->is_success) { - return $response->decoded_content; + if ($url =~ m{^/vi/}) { + $url = 'https://i.ytimg.com' . $url; } - if ($response->status_line() =~ /^401 / and defined($self->get_refresh_token)) { - if (defined(my $refresh_token = $self->oauth_refresh_token())) { - if (defined $refresh_token->{access_token}) { + # Fix YouTube thumbnails for results from invidious instances + $url =~ s{^https?://[^/]+(/vi/.*\.jpg)\z}{https://i.ytimg.com$1}; - $self->set_access_token($refresh_token->{access_token}); + my %lwp_header = ($opt{simple} ? () : $self->_auth_lwp_header); + + my $response = do { + my $r; - # Don't be tempted to use recursion here, because bad things will happen! - $response = $self->{lwp}->get($url, $self->_auth_lwp_header); + if ($url =~ m{^https?://[^/]+\.onion/}) { # onion URL - if ($response->is_success) { - $self->save_authentication_tokens(); - return $response->decoded_content; + if (not defined($self->get_http_proxy)) { # no proxy defined + if ($self->get_env_proxy and (defined($ENV{HTTP_PROXY}) or defined($ENV{HTTPS_PROXY}))) { + ## ok -- LWP::UserAgent will use proxy defined in ENV } - elsif ($response->status_line() =~ /^401 /) { - $self->set_refresh_token(); # refresh token was invalid - $self->set_access_token(); # access token is also broken - warn "[!] Can't refresh the access token! Logging out...\n"; + else { + say ":: Setting proxy for onion websites..." if $self->get_debug; + $self->{lwp}->proxy(['http', 'https'], 'socks://localhost:9050'); + $r = $self->{lwp}->get($url, %lwp_header); + $self->{lwp}->proxy(['http', 'https'], undef); } } - else { - warn "[!] Can't get the access_token! Logging out...\n"; - $self->set_refresh_token(); - $self->set_access_token(); - } - } - else { - warn "[!] Invalid refresh_token! Logging out...\n"; - $self->set_refresh_token(); - $self->set_access_token(); } + + $r // $self->{lwp}->get($url, %lwp_header); + }; + + if ($response->is_success) { + return $response->decoded_content; } $opt{depth} ||= 0; # Try again on 500+ HTTP errors - if ( $opt{depth} < 3 + if ( $opt{depth} < 1 and $response->code() >= 500 and $response->status_line() =~ /(?:Temporary|Server) Error|Timeout|Service Unavailable/i) { return $self->lwp_get($url, %opt, depth => $opt{depth} + 1); } # Too many errors. Pick another invidious instance. - $self->pick_and_set_random_instance(); + if ($url !~ m{\byoutube\.com\b/}) { + $self->pick_and_set_random_instance(); + } _warn_reponse_error($response, $url); return; @@ -527,7 +546,7 @@ sub get_invidious_instances { my $lwp = LWP::UserAgent->new(timeout => $self->get_timeout); $lwp->show_progress(1) if $self->get_debug; - my $resp = $lwp->get("https://instances.invidio.us/instances.json"); + my $resp = $lwp->get("https://api.invidious.io/instances.json"); $resp->is_success() or return; @@ -558,12 +577,17 @@ sub select_good_invidious_instances { 'yewtu.be' => 1, 'invidious.tube' => 1, 'invidiou.site' => 0, + 'invidious.site' => 1, + 'invidious.zee.li' => 1, + 'invidious.048596.xyz' => 1, 'invidious.xyz' => 1, 'vid.mint.lgbt' => 1, 'invidious.ggc-project.de' => 1, 'invidious.toot.koeln' => 1, - 'invidious.kavin.rocks' => 0, + 'invidious.kavin.rocks' => 1, 'invidious.snopyta.org' => 0, + 'invidious.silkky.cloud' => 1, # broken thumbnail URLs for popular videos + 'invidious.moomoo.me' => 1, # ==//== ); #<<< @@ -587,25 +611,24 @@ sub select_good_invidious_instances { return @candidates; } -sub pick_good_random_instance { - my ($self) = @_; - - my @candidates = $self->select_good_invidious_instances(); - my @extra_candidates = $self->select_good_invidious_instances(lax => 1); +sub _find_working_instance { + my ($self, $candidates, $extra_candidates) = @_; require List::Util; require WWW::FairViewer::Utils; state $yv_utils = WWW::FairViewer::Utils->new(); - foreach my $instance (List::Util::shuffle(@candidates), List::Util::shuffle(@extra_candidates)) { + foreach my $instance (List::Util::shuffle(@$candidates), List::Util::shuffle(@$extra_candidates)) { ref($instance) eq 'ARRAY' or next; my $uri = $instance->[1]{uri} // next; $uri =~ s{/+\z}{}; # remove trailing '/' - local $self->{api_host} = $uri; + local $self->{api_host} = $uri; + local $self->{prefer_invidious} = 1; + my $results = $self->search_videos('test'); if ($yv_utils->has_entries($results)) { @@ -613,13 +636,32 @@ sub pick_good_random_instance { } } + return; +} + +sub pick_random_instance { + my ($self) = @_; + + my @candidates = $self->select_good_invidious_instances(); + my @extra_candidates = $self->select_good_invidious_instances(lax => 1); + + if ($self->get_prefer_invidious) { + if (defined(my $instance = $self->_find_working_instance(\@candidates, \@extra_candidates))) { + return $instance; + } + } + + if (not @candidates) { + @candidates = @extra_candidates; + } + $candidates[rand @candidates]; } sub pick_and_set_random_instance { my ($self) = @_; - my $instance = $self->pick_good_random_instance() // return; + my $instance = $self->pick_random_instance() // return; ref($instance) eq 'ARRAY' or return; @@ -640,8 +682,15 @@ sub get_api_url { $host =~ s{/+\z}{}; # remove trailing '/' - if ($host =~ m{^[-\w]+(?>\.[-\w]+)+\z}) { # no protocol specified - $host = 'https://' . $host; # default to HTTPS + if ($host =~ /\w\.\w/ and $host !~ m{^\w+://}) { # no protocol specified + + my $protocol = 'https://'; # default to HTTPS + + if ($host =~ m{^[^/]+\.onion\z}) { # onion URL + $protocol = 'http://'; # default to HTTP + } + + $host = $protocol . $host; } # Pick a random instance when `--instance=auto` or `--instance=invidio.us`. @@ -725,7 +774,7 @@ sub _extract_from_invidious { invidious.site invidious.fdn.fr invidious.snopyta.org - ); + ); } if ($self->get_debug) { @@ -768,7 +817,7 @@ sub _ytdl_is_available { ($self->proxy_stdout($self->get_ytdl_cmd(), '--version') // '') =~ /\d/; } -sub _extract_from_ytdl { +sub _info_from_ytdl { my ($self, $videoID) = @_; $self->_ytdl_is_available() || return; @@ -782,9 +831,23 @@ sub _extract_from_ytdl { } my $json = $self->proxy_stdout(@ytdl_cmd, quotemeta("https://www.youtube.com/watch?v=" . $videoID)); - my $ref = $self->parse_json_string($json); + my $ref = $self->parse_json_string($json // return); + + if ($self->get_debug >= 3) { + require Data::Dump; + Data::Dump::pp($ref); + } + + return $ref; +} + +sub _extract_from_ytdl { + my ($self, $videoID) = @_; + + my $ref = $self->_info_from_ytdl($videoID) // return; my @formats; + if (ref($ref) eq 'HASH' and exists($ref->{formats}) and ref($ref->{formats}) eq 'ARRAY') { foreach my $format (@{$ref->{formats}}) { if (exists($format->{format_id}) and exists($format->{url})) { @@ -825,9 +888,9 @@ sub _fallback_extract_urls { @formats && return @formats; } - # Use the API of invidio.us + # Use the API of invidious if ($self->get_debug) { - say STDERR ":: Using invidio.us to extract the streaming URLs..."; + say STDERR ":: Using invidious to extract the streaming URLs..."; } push @formats, $self->_extract_from_invidious($videoID); @@ -1029,7 +1092,7 @@ sub _extract_streaming_urls { @results = grep { $_->{itag} == 22 or (exists($_->{contentLength}) and $_->{contentLength} > 0) } @results; # Filter out streams with "dur=0.000" - @results = grep { $_->{url} !~ /\bdur=0\.000\b/ } @results; + @results = grep { $_->{url} !~ /\bdur=0\.000\b/ } grep { defined($_->{url}) } @results; # Detect livestream if (!@results and exists($json->{streamingData}) and exists($json->{streamingData}{hlsManifestUrl})) { @@ -1053,7 +1116,36 @@ sub _extract_streaming_urls { return @results; } -sub _get_video_info { +sub _get_youtubei_content { + my ($self, $endpoint, $videoID) = @_; + + # Valid endpoints: browse, player, next + + my $url = sprintf($self->get_youtubei_url(), $endpoint); + + require Time::Piece; + + local $self->{access_token} = undef; + my $content = $self->post_as_json( + $url, + scalar { + "videoId" => $videoID, + "context" => { + "client" => { + "hl" => "en", + "gl" => "US", + "clientName" => "WEB", + "clientVersion" => + sprintf("2.%s.05.00", Time::Piece->new(time)->strftime("%Y%m%d")), + } + } + } + ); + + return $content; +} + +sub _old_get_video_info { my ($self, $videoID) = @_; my $url = $self->get_video_info_url() . sprintf($self->get_video_info_args(), $videoID); @@ -1063,6 +1155,109 @@ sub _get_video_info { return %info; } +sub _get_video_info { + my ($self, $videoID) = @_; + + my ($content, %info); + + for (1 .. 1) { + $content = $self->_get_youtubei_content('player', $videoID) // return $self->_old_get_video_info($videoID); + %info = (player_response => $content); + } + + return %info; +} + +sub _get_video_next_info { + my ($self, $videoID) = @_; + $self->_get_youtubei_content('next', $videoID); +} + +sub _make_translated_captions { + my ($self, $caption_urls) = @_; + + my @languages = qw( + af am ar az be bg bn bs ca ceb co cs cy da de el en eo es et eu fa fi fil + fr fy ga gd gl gu ha haw hi hmn hr ht hu hy id ig is it iw ja jv ka kk km + kn ko ku ky la lb lo lt lv mg mi mk ml mn mr ms mt my ne nl no ny or pa pl + ps pt ro ru rw sd si sk sl sm sn so sq sr st su sv sw ta te tg th tk tr tt + ug uk ur uz vi xh yi yo zh-Hans zh-Hant zu + ); + + my %trans_languages = map { $_->{languageCode} => 1 } @$caption_urls; + @languages = grep { not exists $trans_languages{$_} } @languages; + + my @asr; + foreach my $caption (@$caption_urls) { + foreach my $lang_code (@languages) { + my %caption_copy = %$caption; + $caption_copy{languageCode} = $lang_code; + $caption_copy{baseUrl} = $caption_copy{baseUrl} . "&tlang=$lang_code"; + push @asr, \%caption_copy; + } + } + + return @asr; +} + +sub _fallback_extract_captions { + my ($self, $videoID) = @_; + + if ($self->get_debug) { + say STDERR ":: Extracting closed-caption URLs with `hypervideo`..."; + } + + # Extract closed-caption URLs with hypervideo if our code failed + my $ytdl_info = $self->_info_from_ytdl($videoID); + + my @caption_urls; + + if (defined($ytdl_info) and ref($ytdl_info) eq 'HASH') { + + my $has_subtitles = 0; + + foreach my $key (qw(subtitles automatic_captions)) { + + my $ccaps = $ytdl_info->{$key} // next; + + ref($ccaps) eq 'HASH' or next; + + foreach my $lang_code (sort keys %$ccaps) { + + my ($caption_info) = grep { $_->{ext} eq 'srv1' } @{$ccaps->{$lang_code}}; + + if (defined($caption_info) and ref($caption_info) eq 'HASH' and defined($caption_info->{url})) { + + push @caption_urls, + scalar { + kind => ($key eq 'automatic_captions' ? 'asr' : ''), + languageCode => $lang_code, + baseUrl => $caption_info->{url}, + }; + + if ($key eq 'subtitles') { + $has_subtitles = 1; + } + } + } + + last if $has_subtitles; + } + + # Auto-translated captions + if ($has_subtitles) { + + if ($self->get_debug) { + say STDERR ":: Generating translated closed-caption URLs..."; + } + + push @caption_urls, $self->_make_translated_captions(\@caption_urls); + } + } + + return @caption_urls; +} + =head2 get_streaming_urls($videoID) Returns a list of streaming URLs for a videoID. @@ -1077,15 +1272,35 @@ sub get_streaming_urls { my @streaming_urls = $self->_extract_streaming_urls(\%info, $videoID); my @caption_urls; - if (exists $info{player_response}) { + + if (defined $info{player_response}) { my $captions_json = $info{player_response}; # don't run uri_unescape() on this my $caption_data = $self->parse_json_string($captions_json); if (eval { ref($caption_data->{captions}{playerCaptionsTracklistRenderer}{captionTracks}) eq 'ARRAY' }) { - push @caption_urls, @{$caption_data->{captions}{playerCaptionsTracklistRenderer}{captionTracks}}; + + my @caption_tracks = @{$caption_data->{captions}{playerCaptionsTracklistRenderer}{captionTracks}}; + my @human_made_cc = grep { ($_->{kind} // '') ne 'asr' } @caption_tracks; + + push @caption_urls, @human_made_cc, @caption_tracks; + + foreach my $caption (@caption_urls) { + $caption->{baseUrl} =~ s{\bfmt=srv[0-9]\b}{fmt=srv1}g; + } + + push @caption_urls, $self->_make_translated_captions(\@caption_urls); + } + + # Try again with hypervideo + if (!@streaming_urls or (($caption_data->{playabilityStatus}{status} // '') =~ /fail|error/i)) { + @streaming_urls = $self->_fallback_extract_urls($videoID); + push @caption_urls, $self->_fallback_extract_captions($videoID); } } + else { + push @caption_urls, $self->_fallback_extract_captions($videoID); + } if ($self->get_debug) { my $count = scalar(@streaming_urls); @@ -1093,8 +1308,9 @@ sub get_streaming_urls { } # Try again with hypervideo - if (!@streaming_urls or $info{status} =~ /fail|error/i) { + if (!@streaming_urls or (($info{status} // '') =~ /fail|error/i)) { @streaming_urls = $self->_fallback_extract_urls($videoID); + push @caption_urls, $self->_fallback_extract_captions($videoID); } if ($self->get_prefer_mp4 or $self->get_prefer_av1) { @@ -1208,6 +1424,31 @@ sub post_as_json { sub next_page_with_token { my ($self, $url, $token) = @_; + if (ref($token) eq 'CODE') { + return $token->(); + } + + if ($token =~ /^yt(search|browse):(\w+):(.*)/) { + if ($1 eq 'browse') { + return $self->yt_browse_next_page($url, $3, type => $2, url => $url); + } + else { + return $self->yt_search_next_page($url, $3, type => $2, url => $url); + } + } + + if ($token =~ /^ytplaylist:(\w+):(.*)/) { + return $self->yt_playlist_next_page($url, $2, type => $1, url => $url); + } + + if ($url =~ m{^https://m\.youtube\.com}) { + return + scalar { + url => $url, + results => [], + }; + } + if (not $url =~ s{[?&]continuation=\K([^&]+)}{$token}) { $url = $self->_append_url_args($url, continuation => $token); } @@ -1224,6 +1465,14 @@ sub next_page { return $self->next_page_with_token($url, $token); } + if ($url =~ m{^https://m\.youtube\.com}) { + return + scalar { + url => $url, + results => [], + }; + } + if (not $url =~ s{[?&]page=\K(\d+)}{$1+1}e) { $url = $self->_append_url_args($url, page => 2); } @@ -1233,16 +1482,6 @@ sub next_page { return $res; } -sub previous_page { - my ($self, $url) = @_; - - $url =~ s{[?&]page=\K(\d+)}{($1 > 2) ? ($1-1) : 1}e; - - my $res = $self->_get_results($url); - $res->{url} = $url; - return $res; -} - # SUBROUTINE FACTORY { no strict 'refs'; @@ -1276,13 +1515,14 @@ Trizen, C<< >> Jesus, C<< >> + =head1 SEE ALSO https://developers.google.com/youtube/v3/docs/ =head1 LICENSE AND COPYRIGHT -Copyright 2013-2015 Trizen. +Copyright 2012-2015 Trizen. Copyright 2020 Jesus E. @@ -1322,6 +1562,7 @@ CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + =cut 1; # End of WWW::FairViewer diff --git a/lib/WWW/FairViewer/Channels.pm b/lib/WWW/FairViewer/Channels.pm index 3ee44d4..55598d0 100644 --- a/lib/WWW/FairViewer/Channels.pm +++ b/lib/WWW/FairViewer/Channels.pm @@ -25,12 +25,18 @@ sub _make_channels_url { sub videos_from_channel_id { my ($self, $channel_id) = @_; - return $self->_get_results($self->_make_feed_url("channels/$channel_id/videos")); + + if (my $results = $self->yt_channel_uploads($channel_id)) { + return $results; + } + + my $url = $self->_make_feed_url("channels/$channel_id/videos"); + return $self->_get_results($url); } sub videos_from_username { my ($self, $channel_id) = @_; - return $self->_get_results($self->_make_feed_url("channels/$channel_id/videos")); + $self->videos_from_channel_id($channel_id); } =head2 popular_videos($channel_id) @@ -46,7 +52,12 @@ sub popular_videos { return $self->_get_results($self->_make_feed_url('popular')); } - return $self->_get_results($self->_make_feed_url("channels/$channel_id/videos", sort_by => 'popular')); + if (my $results = $self->yt_channel_uploads($channel_id, sort_by => 'popular')) { + return $results; + } + + my $url = $self->_make_feed_url("channels/$channel_id/videos", sort_by => 'popular'); + return $self->_get_results($url); } =head2 channels_from_categoryID($category_id) @@ -94,65 +105,34 @@ For all functions, C<$channels->{results}{items}> contains: } } -=head2 my_channel() - -Returns info about the channel of the current authenticated user. - -=cut - -sub my_channel { - my ($self) = @_; - $self->get_access_token() // return; - return $self->_get_results($self->_make_channels_url(part => 'snippet', mine => 'true')); -} - -=head2 my_channel_id() +=head2 channel_id_from_username($username) -Returns the channel ID of the current authenticated user. +Return the channel ID for an username. =cut -sub my_channel_id { - my ($self) = @_; +sub channel_id_from_username { + my ($self, $username) = @_; state $cache = {}; - if (exists $cache->{id}) { - return $cache->{id}; + if (exists $cache->{username}) { + return $cache->{username}; } - $cache->{id} = undef; - my $channel = $self->my_channel() // return; - $cache->{id} = $channel->{results}{items}[0]{id} // return; -} - -=head2 channels_my_subscribers() - -Retrieve a list of channels that subscribed to the authenticated user's channel. - -=cut - -sub channels_my_subscribers { - my ($self) = @_; - $self->get_access_token() // return; - return $self->_get_results($self->_make_channels_url(mySubscribers => 'true')); -} - -=head2 channel_id_from_username($username) - -Return the channel ID for an username. - -=cut - -sub channel_id_from_username { - my ($self, $username) = @_; + if (defined(my $id = $self->yt_channel_id($username))) { + if (ref($id) eq '' and $id =~ /\S/) { + $cache->{$username} = $id; + return $id; + } + } # A channel's username (if it doesn't include spaces) is also valid in place of ucid. if ($username =~ /\w/ and not $username =~ /\s/) { return $username; } - # TODO: resolve channel name to channel ID + # Unable to resolve channel name to channel ID (return as it is) return $username; } @@ -165,11 +145,22 @@ Return the channel title for a given channel ID. sub channel_title_from_id { my ($self, $channel_id) = @_; - if ($channel_id eq 'mine') { - $channel_id = $self->my_channel_id(); + $channel_id // return; + + state $cache = {}; + + if (exists $cache->{channel_id}) { + return $cache->{channel_id}; + } + + if (defined(my $title = $self->yt_channel_title($channel_id))) { + if (ref($title) eq '' and $title =~ /\S/) { + $cache->{$channel_id} = $title; + return $title; + } } - my $info = $self->channels_info($channel_id // return) // return; + my $info = $self->channels_info($channel_id) // return; ( ref($info) eq 'HASH' and ref($info->{results}) eq 'HASH' diff --git a/lib/WWW/FairViewer/CommentThreads.pm b/lib/WWW/FairViewer/CommentThreads.pm index 760756e..9bceff5 100644 --- a/lib/WWW/FairViewer/CommentThreads.pm +++ b/lib/WWW/FairViewer/CommentThreads.pm @@ -73,6 +73,7 @@ Trizen, C<< >> Jesus, C<< >> + =head1 SUPPORT You can find documentation for this module with the perldoc command. diff --git a/lib/WWW/FairViewer/GetCaption.pm b/lib/WWW/FairViewer/GetCaption.pm index 710a2af..d919fe9 100644 --- a/lib/WWW/FairViewer/GetCaption.pm +++ b/lib/WWW/FairViewer/GetCaption.pm @@ -182,17 +182,6 @@ sub xml2srt { return join("\n\n", @text); } -=head2 get_xml_data($caption_data) - -Get the XML content for a given caption data. - -=cut - -sub get_xml_data { - my ($self, $url) = @_; - $self->{yv_obj}->lwp_get($url, simple => 1); -} - =head2 save_caption($video_ID) Save the caption in a .srt file and return its file path. @@ -213,8 +202,9 @@ sub save_caption { return $srt_file if (-e $srt_file); # Get XML data, then transform it to SubRip data - my $xml = $self->get_xml_data($info->{baseUrl} // return) // return; - my $srt = $self->xml2srt($xml) // return; + my $url = $info->{baseUrl} // return; + my $xml = $self->{yv_obj}->lwp_get($url, simple => 1) // return; + my $srt = $self->xml2srt($xml) // return; # Write the SubRib data to the $srt_file open(my $fh, '>:utf8', $srt_file) or return; diff --git a/lib/WWW/FairViewer/GuideCategories.pm b/lib/WWW/FairViewer/GuideCategories.pm index cead9f6..86dfe0f 100644 --- a/lib/WWW/FairViewer/GuideCategories.pm +++ b/lib/WWW/FairViewer/GuideCategories.pm @@ -64,6 +64,7 @@ Trizen, C<< >> Jesus, C<< >> + =head1 SUPPORT You can find documentation for this module with the perldoc command. diff --git a/lib/WWW/FairViewer/InitialData.pm b/lib/WWW/FairViewer/InitialData.pm new file mode 100644 index 0000000..50ea500 --- /dev/null +++ b/lib/WWW/FairViewer/InitialData.pm @@ -0,0 +1,1050 @@ +package WWW::FairViewer::InitialData; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::FairViewer::InitialData - Extract initial data. + +=head1 SYNOPSIS + + use WWW::FairViewer; + my $obj = WWW::FairViewer->new(%opts); + + my $results = $obj->yt_search(q => $keywords); + my $playlists = $obj->yt_channel_playlists($channel_ID); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _time_to_seconds { + my ($time) = @_; + + my ($hours, $minutes, $seconds) = (0, 0, 0); + + if ($time =~ /(\d+):(\d+):(\d+)/) { + ($hours, $minutes, $seconds) = ($1, $2, $3); + } + elsif ($time =~ /(\d+):(\d+)/) { + ($minutes, $seconds) = ($1, $2); + } + elsif ($time =~ /(\d+)/) { + $seconds = $1; + } + + $hours * 3600 + $minutes * 60 + $seconds; +} + +sub _human_number_to_int { + my ($text) = @_; + + # 7.6K -> 7600; 7.6M -> 7600000 + if ($text =~ /([\d,.]+)\s*([KMB])/i) { + + my $v = $1; + my $u = $2; + my $m = ($u eq 'K' ? 1e3 : ($u eq 'M' ? 1e6 : ($u eq 'B' ? 1e9 : 1))); + + $v =~ tr/,/./; + + return int($v * $m); + } + + if ($text =~ /([\d,.]+)/) { + my $v = $1; + $v =~ tr/,.//d; + return int($v); + } + + return 0; +} + +sub _thumbnail_quality { + my ($width) = @_; + + $width // return 'medium'; + + if ($width == 1280) { + return "maxres"; + } + + if ($width == 640) { + return "sddefault"; + } + + if ($width == 480) { + return 'high'; + } + + if ($width == 320) { + return 'medium'; + } + + if ($width == 120) { + return 'default'; + } + + if ($width <= 120) { + return 'small'; + } + + if ($width <= 176) { + return 'medium'; + } + + if ($width <= 480) { + return 'high'; + } + + if ($width <= 640) { + return 'sddefault'; + } + + if ($width <= 1280) { + return "maxres"; + } + + return 'medium'; +} + +sub _fix_url_protocol { + my ($url) = @_; + + $url // return undef; + + if ($url =~ m{^https://}) { # ok + return $url; + } + if ($url =~ s{^.*?//}{}) { + return "https://" . $url; + } + if ($url =~ /^\w+\./) { + return "https://" . $url; + } + + return $url; +} + +sub _unscramble { + my ($str) = @_; + + my $i = my $l = length($str); + + $str =~ s/(.)(.{$i})/$2$1/sg while (--$i > 0); + $str =~ s/(.)(.{$i})/$2$1/sg while (++$i < $l); + + return $str; +} + +sub _extract_youtube_mix { + my ($self, $data) = @_; + + my $info = eval { $data->{callToAction}{watchCardHeroVideoRenderer} } || return; + my $header = eval { $data->{header}{watchCardRichHeaderRenderer} }; + + my %mix; + + $mix{type} = 'playlist'; + + $mix{title} = + eval { $header->{title}{runs}[0]{text} } + // eval { $info->{accessibility}{accessibilityData}{label} } + // eval { $info->{callToActionButton}{callToActionButtonRenderer}{label}{runs}[0]{text} } // 'Youtube Mix'; + + $mix{playlistId} = eval { $info->{navigationEndpoint}{watchEndpoint}{playlistId} } || return; + + $mix{playlistThumbnail} = eval { _fix_url_protocol($header->{avatar}{thumbnails}[0]{url}) } + // eval { _fix_url_protocol($info->{heroImage}{collageHeroImageRenderer}{leftThumbnail}{thumbnails}[0]{url}) }; + + $mix{description} = _extract_description({title => $info}); + + $mix{author} = eval { $header->{title}{runs}[0]{text} } // "YouTube"; + $mix{authorId} = eval { $header->{titleNavigationEndpoint}{browseEndpoint}{browseId} } // "youtube"; + + return \%mix; +} + +sub _extract_author_name { + my ($info) = @_; + eval { $info->{longBylineText}{runs}[0]{text} } // eval { $info->{shortBylineText}{runs}[0]{text} }; +} + +sub _extract_video_id { + my ($info) = @_; + eval { $info->{videoId} } || eval { $info->{navigationEndpoint}{watchEndpoint}{videoId} } || undef; +} + +sub _extract_length_seconds { + my ($info) = @_; + eval { $info->{lengthSeconds} } + || _time_to_seconds(eval { $info->{thumbnailOverlays}[0]{thumbnailOverlayTimeStatusRenderer}{text}{runs}[0]{text} } // 0) + || _time_to_seconds(eval { $info->{lengthText}{runs}[0]{text} // 0 }); +} + +sub _extract_published_text { + my ($info) = @_; + + my $text = eval { $info->{publishedTimeText}{runs}[0]{text} } || return undef; + + if ($text =~ /(\d+)\s+(\w+)/) { + return "$1 $2 ago"; + } + + if ($text =~ /(\d+)\s*(\w+)/) { + return "$1 $2 ago"; + } + + return $text; +} + +sub _extract_channel_id { + my ($info) = @_; + eval { $info->{channelId} } + // eval { $info->{shortBylineText}{runs}[0]{navigationEndpoint}{browseEndpoint}{browseId} } + // eval { $info->{navigationEndpoint}{browseEndpoint}{browseId} }; +} + +sub _extract_view_count_text { + my ($info) = @_; + eval { $info->{shortViewCountText}{runs}[0]{text} }; +} + +sub _extract_thumbnails { + my ($info) = @_; + eval { + [ + map { + my %thumb = %$_; + $thumb{quality} = _thumbnail_quality($thumb{width}); + $thumb{url} = _fix_url_protocol($thumb{url}); + \%thumb; + } @{$info->{thumbnail}{thumbnails}} + ] + }; +} + +sub _extract_playlist_thumbnail { + my ($info) = @_; + eval { + _fix_url_protocol( + ( + grep { _thumbnail_quality($_->{width}) =~ /medium|high/ } + @{$info->{thumbnailRenderer}{playlistVideoThumbnailRenderer}{thumbnail}{thumbnails}} + )[0]{url} // $info->{thumbnailRenderer}{playlistVideoThumbnailRenderer}{thumbnail}{thumbnails}[0]{url} + ); + } // eval { + _fix_url_protocol((grep { _thumbnail_quality($_->{width}) =~ /medium|high/ } @{$info->{thumbnail}{thumbnails}})[0]{url} + // $info->{thumbnail}{thumbnails}[0]{url}); + }; +} + +sub _extract_title { + my ($info) = @_; + eval { $info->{title}{runs}[0]{text} } // eval { $info->{title}{accessibility}{accessibilityData}{label} }; +} + +sub _extract_description { + my ($info) = @_; + + # FIXME: this is not the video description + eval { $info->{title}{accessibility}{accessibilityData}{label} }; +} + +sub _extract_view_count { + my ($info) = @_; + _human_number_to_int(eval { $info->{viewCountText}{runs}[0]{text} } || 0); +} + +sub _extract_video_count { + my ($info) = @_; + _human_number_to_int( eval { $info->{videoCountShortText}{runs}[0]{text} } + || eval { $info->{videoCountText}{runs}[0]{text} } + || 0); +} + +sub _extract_subscriber_count { + my ($info) = @_; + _human_number_to_int(eval { $info->{subscriberCountText}{runs}[0]{text} } || 0); +} + +sub _extract_playlist_id { + my ($info) = @_; + eval { $info->{playlistId} }; +} + +sub _extract_itemSection_entry { + my ($self, $data, %args) = @_; + + ref($data) eq 'HASH' or return; + + # Album + if ($args{type} eq 'all' and exists $data->{horizontalCardListRenderer}) { # TODO + return; + } + + # Video + if (exists($data->{compactVideoRenderer}) or exists($data->{playlistVideoRenderer})) { + + my %video; + my $info = $data->{compactVideoRenderer} // $data->{playlistVideoRenderer}; + + $video{type} = 'video'; + + # Deleted video + if (defined(eval { $info->{isPlayable} }) and not $info->{isPlayable}) { + return; + } + + $video{videoId} = _extract_video_id($info) // return; + $video{title} = _extract_title($info) // return; + $video{lengthSeconds} = _extract_length_seconds($info) || 0; + $video{liveNow} = ($video{lengthSeconds} == 0); + $video{author} = _extract_author_name($info); + $video{authorId} = _extract_channel_id($info); + $video{publishedText} = _extract_published_text($info); + $video{viewCountText} = _extract_view_count_text($info); + $video{videoThumbnails} = _extract_thumbnails($info); + $video{description} = _extract_description($info); + $video{viewCount} = _extract_view_count($info); + + # Filter out private/deleted videos from playlists + if (exists($data->{playlistVideoRenderer})) { + $video{author} // return; + $video{authorId} // return; + } + + return \%video; + } + + # Playlist + if ($args{type} ne 'video' and exists $data->{compactPlaylistRenderer}) { + + my %playlist; + my $info = $data->{compactPlaylistRenderer}; + + $playlist{type} = 'playlist'; + + $playlist{title} = _extract_title($info) // return; + $playlist{playlistId} = _extract_playlist_id($info) // return; + $playlist{author} = _extract_author_name($info); + $playlist{authorId} = _extract_channel_id($info); + $playlist{videoCount} = _extract_video_count($info); + $playlist{playlistThumbnail} = _extract_playlist_thumbnail($info); + $playlist{description} = _extract_description($info); + + return \%playlist; + } + + # Channel + if ($args{type} ne 'video' and exists $data->{compactChannelRenderer}) { + + my %channel; + my $info = $data->{compactChannelRenderer}; + + $channel{type} = 'channel'; + + $channel{author} = _extract_title($info) // return; + $channel{authorId} = _extract_channel_id($info) // return; + $channel{subCount} = _extract_subscriber_count($info); + $channel{videoCount} = _extract_video_count($info); + $channel{authorThumbnails} = _extract_thumbnails($info); + $channel{description} = _extract_description($info); + + return \%channel; + } + + return; +} + +sub _parse_itemSection { + my ($self, $entry, %args) = @_; + + eval { ref($entry->{contents}) eq 'ARRAY' } || return; + + my @results; + + foreach my $entry (@{$entry->{contents}}) { + + my $item = $self->_extract_itemSection_entry($entry, %args); + + if (defined($item) and ref($item) eq 'HASH') { + push @results, $item; + } + } + + if (exists($entry->{continuations}) and ref($entry->{continuations}) eq 'ARRAY') { + + my $token = eval { $entry->{continuations}[0]{nextContinuationData}{continuation} }; + + if (defined($token)) { + push @results, + scalar { + type => 'nextpage', + token => "ytplaylist:$args{type}:$token", + }; + } + } + + return @results; +} + +sub _parse_itemSection_nextpage { + my ($self, $entry, %args) = @_; + + eval { ref($entry->{contents}) eq 'ARRAY' } || return; + + foreach my $entry (@{$entry->{contents}}) { + + # Continuation page + if (exists $entry->{continuationItemRenderer}) { + + my $info = $entry->{continuationItemRenderer}; + my $token = eval { $info->{continuationEndpoint}{continuationCommand}{token} }; + + if (defined($token)) { + return + scalar { + type => 'nextpage', + token => "ytbrowse:$args{type}:$token", + }; + } + } + } + + return; +} + +sub _extract_sectionList_results { + my ($self, $data, %args) = @_; + + eval { ref($data->{contents}) eq 'ARRAY' } or return; + + my @results; + + foreach my $entry (@{$data->{contents}}) { + + # Playlists + if (eval { ref($entry->{shelfRenderer}{content}{verticalListRenderer}{items}) eq 'ARRAY' }) { + my $res = {contents => $entry->{shelfRenderer}{content}{verticalListRenderer}{items}}; + push @results, $self->_parse_itemSection($res, %args); + push @results, $self->_parse_itemSection_nextpage($res, %args); + next; + } + + # Playlist videos + if (eval { ref($entry->{itemSectionRenderer}{contents}[0]{playlistVideoListRenderer}{contents}) eq 'ARRAY' }) { + my $res = $entry->{itemSectionRenderer}{contents}[0]{playlistVideoListRenderer}; + push @results, $self->_parse_itemSection($res, %args); + push @results, $self->_parse_itemSection_nextpage($res, %args); + next; + } + + # YouTube Mix + if ($args{type} eq 'all' and exists $entry->{universalWatchCardRenderer}) { + + my $mix = $self->_extract_youtube_mix($entry->{universalWatchCardRenderer}); + + if (defined($mix)) { + push(@results, $mix); + } + } + + # Video results + if (exists $entry->{itemSectionRenderer}) { + my $res = $entry->{itemSectionRenderer}; + push @results, $self->_parse_itemSection($res, %args); + push @results, $self->_parse_itemSection_nextpage($res, %args); + } + + # Continuation page + if (exists $entry->{continuationItemRenderer}) { + + my $info = $entry->{continuationItemRenderer}; + my $token = eval { $info->{continuationEndpoint}{continuationCommand}{token} }; + + if (defined($token)) { + push @results, + scalar { + type => 'nextpage', + token => "ytsearch:$args{type}:$token", + }; + } + } + } + + if (@results and exists $data->{continuations}) { + push @results, $self->_parse_itemSection($data, %args); + } + + return @results; +} + +sub _extract_channel_header { + my ($self, $data, %args) = @_; + eval { $data->{header}{c4TabbedHeaderRenderer} } // eval { $data->{metadata}{channelMetadataRenderer} }; +} + +sub _add_author_to_results { + my ($self, $data, $results, %args) = @_; + + my $header = $self->_extract_channel_header($data, %args); + + my $channel_id = eval { $header->{channelId} } // eval { $header->{externalId} }; + my $channel_name = eval { $header->{title} }; + + foreach my $result (@$results) { + if (ref($result) eq 'HASH') { + $result->{author} = $channel_name if defined($channel_name); + $result->{authorId} = $channel_id if defined($channel_id); + } + } + + return 1; +} + +sub _find_sectionList { + my ($self, $data) = @_; + + eval { + ( + grep { + eval { exists($_->{tabRenderer}{content}{sectionListRenderer}{contents}) } + } @{$data->{contents}{singleColumnBrowseResultsRenderer}{tabs}} + )[0]{tabRenderer}{content}{sectionListRenderer}; + } // undef; +} + +sub _extract_channel_uploads { + my ($self, $data, %args) = @_; + + my @results = $self->_extract_sectionList_results($self->_find_sectionList($data), %args); + $self->_add_author_to_results($data, \@results, %args); + return @results; +} + +sub _extract_channel_playlists { + my ($self, $data, %args) = @_; + + my @results = $self->_extract_sectionList_results($self->_find_sectionList($data), %args); + $self->_add_author_to_results($data, \@results, %args); + return @results; +} + +sub _extract_playlist_videos { + my ($self, $data, %args) = @_; + + my @results = $self->_extract_sectionList_results($self->_find_sectionList($data), %args); + $self->_add_author_to_results($data, \@results, %args); + return @results; +} + +sub _get_initial_data { + my ($self, $url) = @_; + + $self->get_prefer_invidious() and return; + + my $content = $self->lwp_get($url) // return; + + if ($content =~ m{var\s+ytInitialData\s*=\s*'(.*?)'}is) { + my $json = $1; + + $json =~ s{\\x([[:xdigit:]]{2})}{chr(hex($1))}ge; + $json =~ s{\\u([[:xdigit:]]{4})}{chr(hex($1))}ge; + $json =~ s{\\(["&])}{$1}g; + + my $hash = $self->parse_utf8_json_string($json); + return $hash; + } + + if ($content =~ m{
}is) { + my $json = $1; + my $hash = $self->parse_utf8_json_string($json); + return $hash; + } + + return; +} + +sub _channel_data { + my ($self, $channel, %args) = @_; + + state $yv_utils = WWW::FairViewer::Utils->new(); + + my $url = $self->get_m_youtube_url; + + if ($yv_utils->is_channelID($channel)) { + $url .= "/channel/$channel/$args{type}"; + } + else { + $url .= "/c/$channel/$args{type}"; + } + + my %params = (hl => "en"); + + if (defined(my $sort = $args{sort_by})) { + if ($sort eq 'popular') { + $params{sort} = 'p'; + } + elsif ($sort eq 'old') { + $params{sort} = 'da'; + } + } + + if (exists($args{params}) and ref($args{params}) eq 'HASH') { + %params = (%params, %{$args{params}}); + } + + $url = $self->_append_url_args($url, %params); + my $result = $self->_get_initial_data($url); + + # When /c/ failed, try /user/ + if ((!defined($result) or !scalar(keys %$result)) and $url =~ s{/c/}{/user/}) { + $result = $self->_get_initial_data($url); + } + + ($url, $result); +} + +sub _prepare_results_for_return { + my ($self, $results, %args) = @_; + + (defined($results) and ref($results) eq 'ARRAY') || return; + + my @results = @$results; + + @results || return; + + if (@results and $results[-1]{type} eq 'nextpage') { + + my $nextpage = pop(@results); + + if (defined($nextpage->{token}) and @results) { + + if ($self->get_debug) { + say STDERR ":: Returning results with a continuation page token..."; + } + + return { + url => $args{url}, + results => { + entries => \@results, + continuation => $nextpage->{token}, + }, + }; + } + } + + my $url = $args{url}; + + if ($url =~ m{^https://m\.youtube\.com}) { + $url = undef; + } + + return { + url => $url, + results => \@results, + }; +} + +=head2 yt_search(q => $keyword, %args) + +Search for videos given a keyword string (uri-escaped). + +=cut + +sub yt_search { + my ($self, %args) = @_; + + my $url = $self->get_m_youtube_url . "/results?search_query=$args{q}"; + + my @sp; + my %params = (hl => 'en',); + + $args{type} //= 'video'; + + if ($args{type} eq 'video') { + + if (defined(my $duration = $self->get_videoDuration)) { + if ($duration eq 'long') { + push @sp, 'EgQQARgC'; + } + elsif ($duration eq 'short') { + push @sp, 'EgQQARgB'; + } + } + + if (defined(my $date = $self->get_date)) { + if ($date eq 'hour') { + push @sp, 'EgQIARAB'; + } + elsif ($date eq 'today') { + push @sp, "EgQIAhAB"; + } + elsif ($date eq 'week') { + push @sp, "EgQIAxAB"; + } + elsif ($date eq 'month') { + push @sp, "EgQIBBAB"; + } + elsif ($date eq 'year') { + push @sp, "EgQIBRAB"; + } + } + + if (defined(my $order = $self->get_order)) { + if ($order eq 'upload_date') { + push @sp, "CAISAhAB"; + } + elsif ($order eq 'view_count') { + push @sp, "CAMSAhAB"; + } + elsif ($order eq 'rating') { + push @sp, "CAESAhAB"; + } + } + + if (defined(my $license = $self->get_videoLicense)) { + if ($license eq 'creative_commons') { + push @sp, "EgIwAQ%253D%253D"; + } + } + + if (defined(my $vd = $self->get_videoDefinition)) { + if ($vd eq 'high') { + push @sp, "EgIgAQ%253D%253D"; + } + } + + if (defined(my $vc = $self->get_videoCaption)) { + if ($vc eq 'true' or $vc eq '1') { + push @sp, "EgIoAQ%253D%253D"; + } + } + + if (defined(my $vd = $self->get_videoDimension)) { + if ($vd eq '3d') { + push @sp, "EgI4AQ%253D%253D"; + } + } + } + + if ($args{type} eq 'video') { + push @sp, "EgIQAQ%253D%253D"; + } + elsif ($args{type} eq 'playlist') { + push @sp, "EgIQAw%253D%253D"; + } + elsif ($args{type} eq 'channel') { + push @sp, "EgIQAg%253D%253D"; + } + elsif ($args{type} eq 'movie') { # TODO: implement support for movies + push @sp, "EgIQBA%253D%253D"; + } + + $params{sp} = join('+', @sp); + $url = $self->_append_url_args($url, %params); + + my $hash = $self->_get_initial_data($url) // return; + my @results = $self->_extract_sectionList_results(eval { $hash->{contents}{sectionListRenderer} } // undef, %args); + + $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +=head2 yt_channel_search($channel, q => $keyword, %args) + +Search for videos given a keyword string (uri-escaped) from a given channel ID or username. + +=cut + +sub yt_channel_search { + my ($self, $channel, %args) = @_; + my ($url, $hash) = $self->_channel_data($channel, %args, type => 'search', params => {query => $args{q}}); + + $hash // return; + + my @results = $self->_extract_sectionList_results($self->_find_sectionList($hash), %args, type => 'video'); + $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +=head2 yt_channel_uploads($channel, %args) + +Latest uploads for a given channel ID or username. + +=cut + +sub yt_channel_uploads { + my ($self, $channel, %args) = @_; + my ($url, $hash) = $self->_channel_data($channel, %args, type => 'videos'); + + $hash // return; + + my @results = $self->_extract_channel_uploads($hash, %args, type => 'video'); + $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +=head2 yt_channel_info($channel, %args) + +Channel info (such as title) for a given channel ID or username. + +=cut + +sub yt_channel_info { + my ($self, $channel, %args) = @_; + my ($url, $hash) = $self->_channel_data($channel, %args, type => ''); + return $hash; +} + +=head2 yt_channel_title($channel, %args) + +Exact the channel title (as a string) for a given channel ID or username. + +=cut + +sub yt_channel_title { + my ($self, $channel, %args) = @_; + my ($url, $hash) = $self->_channel_data($channel, %args, type => ''); + $hash // return; + my $header = $self->_extract_channel_header($hash, %args) // return; + my $title = eval { $header->{title} }; + return $title; +} + +=head2 yt_channel_id($username, %args) + +Exact the channel ID (as a string) for a given channel username. + +=cut + +sub yt_channel_id { + my ($self, $username, %args) = @_; + my ($url, $hash) = $self->_channel_data($username, %args, type => ''); + $hash // return; + my $header = $self->_extract_channel_header($hash, %args) // return; + my $id = eval { $header->{channelId} } // eval { $header->{externalId} }; + return $id; +} + +=head2 yt_channel_playlists($channel, %args) + +Playlists for a given channel ID or username. + +=cut + +sub yt_channel_playlists { + my ($self, $channel, %args) = @_; + my ($url, $hash) = $self->_channel_data($channel, %args, type => 'playlists'); + + $hash // return; + + my @results = $self->_extract_channel_playlists($hash, %args, type => 'playlist'); + $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +=head2 yt_playlist_videos($playlist_id, %args) + +Videos from a given playlist ID. + +=cut + +sub yt_playlist_videos { + my ($self, $playlist_id, %args) = @_; + + my $url = $self->_append_url_args($self->get_m_youtube_url . "/playlist", list => $playlist_id, hl => "en"); + my $hash = $self->_get_initial_data($url) // return; + + my @results = $self->_extract_sectionList_results($self->_find_sectionList($hash), %args, type => 'video'); + $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +=head2 yt_playlist_next_page($url, $token, %args) + +Load more items from a playlist, given a continuation token. + +=cut + +sub yt_playlist_next_page { + my ($self, $url, $token, %args) = @_; + + my $request_url = $self->_append_url_args($url, ctoken => $token); + my $hash = $self->_get_initial_data($request_url) // return; + + my @results = $self->_parse_itemSection( + eval { $hash->{continuationContents}{playlistVideoListContinuation} } + // eval { $hash->{continuationContents}{itemSectionContinuation} }, + %args + ); + + if (!@results) { + @results = + $self->_extract_sectionList_results(eval { $hash->{continuationContents}{sectionListContinuation} } // undef, %args); + } + + $self->_add_author_to_results($hash, \@results, %args); + $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +sub yt_browse_next_page { + my ($self, $url, $token, %args) = @_; + + my %request = ( + context => { + client => { + browserName => "Firefox", + browserVersion => "83.0", + clientFormFactor => "LARGE_FORM_FACTOR", + clientName => "MWEB", + clientVersion => "2.20210308.03.00", + deviceMake => "Generic", + deviceModel => "Android 11.0", + hl => "en", + mainAppWebInfo => { + graftUrl => $url, + }, + originalUrl => $url, + osName => "Android", + osVersion => "11", + platform => "TABLET", + playerType => "UNIPLAYER", + screenDensityFloat => 1, + screenHeightPoints => 500, + screenPixelDensity => 1, + screenWidthPoints => 1800, + timeZone => "UTC", + userAgent => "Mozilla/5.0 (Android 11; Tablet; rv:83.0) Gecko/83.0 Firefox/83.0,gzip(gfe)", + userInterfaceTheme => "USER_INTERFACE_THEME_LIGHT", + utcOffsetMinutes => 0, + }, + request => { + consistencyTokenJars => [], + internalExperimentFlags => [], + }, + user => {}, + }, + continuation => $token, + ); + + my $content = $self->post_as_json( + $self->get_m_youtube_url . '/youtubei/v1/browse?key=' . _unscramble('1HUCiSlOalFEcYQSS8_9q1LW4y8JAwI2zT_qA_G'), + \%request) // return; + + my $hash = $self->parse_json_string($content); + + my $res = + eval { $hash->{continuationContents}{playlistVideoListContinuation} } + // eval { $hash->{continuationContents}{itemSectionContinuation} } + // eval { {contents => $hash->{onResponseReceivedActions}[0]{appendContinuationItemsAction}{continuationItems}} } + // undef; + + my @results = $self->_parse_itemSection($res, %args); + + if (@results) { + push @results, $self->_parse_itemSection_nextpage($res, %args); + } + + if (!@results) { + @results = + $self->_extract_sectionList_results(eval { $hash->{continuationContents}{sectionListContinuation} } // undef, %args); + } + + $self->_add_author_to_results($hash, \@results, %args); + $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +=head2 yt_search_next_page($url, $token, %args) + +Load more search results, given a continuation token. + +=cut + +sub yt_search_next_page { + my ($self, $url, $token, %args) = @_; + + my %request = ( + "context" => { + "client" => { + "browserName" => "Firefox", + "browserVersion" => "83.0", + "clientFormFactor" => "LARGE_FORM_FACTOR", + "clientName" => "MWEB", + "clientVersion" => "2.20201030.01.00", + "deviceMake" => "generic", + "deviceModel" => "android 11.0", + "gl" => "US", + "hl" => "en", + "mainAppWebInfo" => { + "graftUrl" => "https://m.youtube.com/results?search_query=youtube" + }, + "osName" => "Android", + "osVersion" => "11", + "platform" => "TABLET", + "playerType" => "UNIPLAYER", + "screenDensityFloat" => 1, + "screenHeightPoints" => 420, + "screenPixelDensity" => 1, + "screenWidthPoints" => 1442, + "userAgent" => "Mozilla/5.0 (Android 11; Tablet; rv:83.0) Gecko/83.0 Firefox/83.0,gzip(gfe)", + "userInterfaceTheme" => "USER_INTERFACE_THEME_LIGHT", + "utcOffsetMinutes" => 0, + }, + "request" => { + "consistencyTokenJars" => [], + "internalExperimentFlags" => [], + }, + "user" => {} + }, + "continuation" => $token, + ); + + my $content = $self->post_as_json( + $self->get_m_youtube_url + . _unscramble('o/ebseky?u1ri//hvcuyta=e') + . _unscramble('1HUCiSlOalFEcYQSS8_9q1LW4y8JAwI2zT_qA_G'), + \%request + ) // return; + + my $hash = $self->parse_json_string($content); + + my @results = $self->_extract_sectionList_results( + scalar { + contents => eval { + $hash->{onResponseReceivedCommands}[0]{appendContinuationItemsAction}{continuationItems}; + } // undef + }, + %args + ); + + $self->_prepare_results_for_return(\@results, %args, url => $url); +} + +=head1 AUTHOR + +Trizen, C<< >> + +Jesus, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::FairViewer::InitialData + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 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. + +See L for more information. + +=cut + +1; # End of WWW::FairViewer::InitialData diff --git a/lib/WWW/FairViewer/Itags.pm b/lib/WWW/FairViewer/Itags.pm index 85473c2..856775c 100644 --- a/lib/WWW/FairViewer/Itags.pm +++ b/lib/WWW/FairViewer/Itags.pm @@ -41,90 +41,90 @@ Reference: http://en.wikipedia.org/wiki/YouTube#Quality_and_formats sub get_itags { scalar { - 'best' => [{value => 38, format => 'mp4'}, # mp4 (3072p) (v-a) - {value => 138, format => 'mp4', dash => 1}, # mp4 (2160p-4320p) (v) - {value => 266, format => 'mp4', dash => 1}, # mp4 (2160p-2304p) (v) + 'best' => [{value => 38, format => 'mp4'}, # mp4 (3072p) (v-a) + {value => 138, format => 'mp4', split => 1}, # mp4 (2160p-4320p) (v) + {value => 266, format => 'mp4', split => 1}, # mp4 (2160p-2304p) (v) ], - '2160' => [{value => 315, format => 'webm', dash => 1, hfr => 1}, # webm HFR (v) - {value => 272, format => 'webm', dash => 1}, # webm (v) - {value => 313, format => 'webm', dash => 1}, # webm (v) - {value => 401, format => 'av1', dash => 1}, # av1 (v) + '2160' => [{value => 315, format => 'webm', split => 1, hfr => 1}, # webm HFR (v) + {value => 272, format => 'webm', split => 1}, # webm (v) + {value => 313, format => 'webm', split => 1}, # webm (v) + {value => 401, format => 'av1', split => 1}, # av1 (v) ], - '1440' => [{value => 308, format => 'webm', dash => 1, hfr => 1}, # webm HFR (v) - {value => 271, format => 'webm', dash => 1}, # webm (v) - {value => 264, format => 'mp4', dash => 1}, # mp4 (v) - {value => 400, format => 'av1', dash => 1}, # av1 (v) + '1440' => [{value => 308, format => 'webm', split => 1, hfr => 1}, # webm HFR (v) + {value => 271, format => 'webm', split => 1}, # webm (v) + {value => 264, format => 'mp4', split => 1}, # mp4 (v) + {value => 400, format => 'av1', split => 1}, # av1 (v) ], - '1080' => [{value => 303, format => 'webm', dash => 1, hfr => 1}, # webm HFR (v) - {value => 299, format => 'mp4', dash => 1, hfr => 1}, # mp4 HFR (v) - {value => 248, format => 'webm', dash => 1}, # webm (v) - {value => 137, format => 'mp4', dash => 1}, # mp4 (v) - {value => 399, format => 'av1', dash => 1, hfr => 1}, # av1 (v) - {value => 46, format => 'webm'}, # webm (v-a) - {value => 37, format => 'mp4'}, # mp4 (v-a) - {value => 301, format => 'mp4', live => 1}, # mp4 (live) (v-a) - {value => 96, format => 'ts', live => 1}, # ts (live) (v-a) + '1080' => [{value => 303, format => 'webm', split => 1, hfr => 1}, # webm HFR (v) + {value => 299, format => 'mp4', split => 1, hfr => 1}, # mp4 HFR (v) + {value => 248, format => 'webm', split => 1}, # webm (v) + {value => 137, format => 'mp4', split => 1}, # mp4 (v) + {value => 399, format => 'av1', split => 1, hfr => 1}, # av1 (v) + {value => 46, format => 'webm'}, # webm (v-a) + {value => 37, format => 'mp4'}, # mp4 (v-a) + {value => 301, format => 'mp4', live => 1}, # mp4 (live) (v-a) + {value => 96, format => 'ts', live => 1}, # ts (live) (v-a) ], - '720' => [{value => 302, format => 'webm', dash => 1, hfr => 1}, # webm HFR (v) - {value => 298, format => 'mp4', dash => 1, hfr => 1}, # mp4 HFR (v) - {value => 247, format => 'webm', dash => 1}, # webm (v) - {value => 136, format => 'mp4', dash => 1}, # mp4 (v) - {value => 398, format => 'av1', dash => 1, hfr => 1}, # av1 (v) - {value => 45, format => 'webm'}, # webm (v-a) - {value => 22, format => 'mp4'}, # mp4 (v-a) - {value => 300, format => 'mp4', live => 1}, # mp4 (live) (v-a) - {value => 120, format => 'flv', live => 1}, # flv (live) (v-a) - {value => 95, format => 'ts', live => 1}, # ts (live) (v-a) + '720' => [{value => 302, format => 'webm', split => 1, hfr => 1}, # webm HFR (v) + {value => 298, format => 'mp4', split => 1, hfr => 1}, # mp4 HFR (v) + {value => 247, format => 'webm', split => 1}, # webm (v) + {value => 136, format => 'mp4', split => 1}, # mp4 (v) + {value => 398, format => 'av1', split => 1, hfr => 1}, # av1 (v) + {value => 45, format => 'webm'}, # webm (v-a) + {value => 22, format => 'mp4'}, # mp4 (v-a) + {value => 300, format => 'mp4', live => 1}, # mp4 (live) (v-a) + {value => 120, format => 'flv', live => 1}, # flv (live) (v-a) + {value => 95, format => 'ts', live => 1}, # ts (live) (v-a) ], - '480' => [{value => 244, format => 'webm', dash => 1}, # webm (v) - {value => 135, format => 'mp4', dash => 1}, # mp4 (v) - {value => 397, format => 'av1', dash => 1}, # av1 (v) - {value => 44, format => 'webm'}, # webm (v-a) - {value => 35, format => 'flv'}, # flv (v-a) - {value => 94, format => 'mp4', live => 1}, # mp4 (live) (v-a) + '480' => [{value => 244, format => 'webm', split => 1}, # webm (v) + {value => 135, format => 'mp4', split => 1}, # mp4 (v) + {value => 397, format => 'av1', split => 1}, # av1 (v) + {value => 44, format => 'webm'}, # webm (v-a) + {value => 35, format => 'flv'}, # flv (v-a) + {value => 94, format => 'mp4', live => 1}, # mp4 (live) (v-a) ], - '360' => [{value => 243, format => 'webm', dash => 1}, # webm (v) - {value => 134, format => 'mp4', dash => 1}, # mp4 (v) - {value => 396, format => 'av1', dash => 1}, # av1 (v) - {value => 43, format => 'webm'}, # webm (v-a) - {value => 34, format => 'flv'}, # flv (v-a) - {value => 93, format => 'mp4', live => 1}, # mp4 (live) (v-a) - {value => 18, format => 'mp4'}, # mp4 (v-a) + '360' => [{value => 243, format => 'webm', split => 1}, # webm (v) + {value => 134, format => 'mp4', split => 1}, # mp4 (v) + {value => 396, format => 'av1', split => 1}, # av1 (v) + {value => 43, format => 'webm'}, # webm (v-a) + {value => 34, format => 'flv'}, # flv (v-a) + {value => 93, format => 'mp4', live => 1}, # mp4 (live) (v-a) + {value => 18, format => 'mp4'}, # mp4 (v-a) ], - '240' => [{value => 242, format => 'webm', dash => 1}, # webm (v) - {value => 133, format => 'mp4', dash => 1}, # mp4 (v) - {value => 395, format => 'av1', dash => 1}, # av1 (v) - {value => 6, format => 'flv'}, # flv (270p) (v-a) - {value => 5, format => 'flv'}, # flv (v-a) - {value => 36, format => '3gp'}, # 3gp (v-a) - {value => 13, format => '3gp'}, # 3gp (v-a) - {value => 92, format => 'mp4', live => 1}, # mp4 (live) (v-a) - {value => 132, format => 'ts', live => 1}, # ts (live) (v-a) + '240' => [{value => 242, format => 'webm', split => 1}, # webm (v) + {value => 133, format => 'mp4', split => 1}, # mp4 (v) + {value => 395, format => 'av1', split => 1}, # av1 (v) + {value => 6, format => 'flv'}, # flv (270p) (v-a) + {value => 5, format => 'flv'}, # flv (v-a) + {value => 36, format => '3gp'}, # 3gp (v-a) + {value => 13, format => '3gp'}, # 3gp (v-a) + {value => 92, format => 'mp4', live => 1}, # mp4 (live) (v-a) + {value => 132, format => 'ts', live => 1}, # ts (live) (v-a) ], - '144' => [{value => 278, format => 'webm', dash => 1}, # webm (v) - {value => 160, format => 'mp4', dash => 1}, # mp4 (v) - {value => 394, format => 'av1', dash => 1}, # av1 (v) - {value => 17, format => '3gp'}, # 3gp (v-a) - {value => 91, format => 'mp4'}, # mp4 (live) (v-a) - {value => 151, format => 'ts'}, # ts (live) (v-a) + '144' => [{value => 278, format => 'webm', split => 1}, # webm (v) + {value => 160, format => 'mp4', split => 1}, # mp4 (v) + {value => 394, format => 'av1', split => 1}, # av1 (v) + {value => 17, format => '3gp'}, # 3gp (v-a) + {value => 91, format => 'mp4'}, # mp4 (live) (v-a) + {value => 151, format => 'ts'}, # ts (live) (v-a) ], - 'audio' => [{value => 172, format => 'webm', kbps => 192}, # webm (192 kbps) - {value => 251, format => 'opus', kbps => 160}, # webm opus (128-160 kbps) - {value => 171, format => 'webm', kbps => 128}, # webm vorbis (92-128 kbps) - {value => 140, format => 'm4a', kbps => 128}, # mp4a (128 kbps) - {value => 141, format => 'm4a', kbps => 256}, # mp4a (256 kbps) - {value => 250, format => 'opus', kbps => 64}, # webm opus (64 kbps) - {value => 249, format => 'opus', kbps => 48}, # webm opus (48 kbps) - {value => 139, format => 'm4a', kbps => 48}, # mp4a (48 kbps) + 'audio' => [{value => 172, format => 'webm', kbps => 192}, # webm (192 kbps) + {value => 251, format => 'opus', kbps => 160}, # webm opus (128-160 kbps) + {value => 171, format => 'webm', kbps => 128}, # webm vorbis (92-128 kbps) + {value => 140, format => 'm4a', kbps => 128}, # mp4a (128 kbps) + {value => 141, format => 'm4a', kbps => 256}, # mp4a (256 kbps) + {value => 250, format => 'opus', kbps => 64}, # webm opus (64 kbps) + {value => 249, format => 'opus', kbps => 48}, # webm opus (48 kbps) + {value => 139, format => 'm4a', kbps => 48}, # mp4a (48 kbps) ], }; } @@ -176,12 +176,19 @@ sub _find_streaming_url { $args{ignore_av1} && next; # ignore videos in AV1 format } - if ($itag->{dash}) { + # Ignored video projections + if (ref($args{ignored_projections}) eq 'ARRAY') { + if (grep { lc($entry->{projectionType} // '') eq lc($_) } @{$args{ignored_projections}}) { + next; + } + } + + if ($itag->{split}) { - $args{dash} || next; + $args{split} || next; my $video_info = $stream->{$itag->{value}}; - my $audio_info = $self->_find_streaming_url(%args, resolution => 'audio', dash => 0); + my $audio_info = $self->_find_streaming_url(%args, resolution => 'audio', split => 0); if (defined($audio_info)) { $video_info->{__AUDIO__} = $audio_info; @@ -191,14 +198,14 @@ sub _find_streaming_url { next; } - if ($resolution eq 'audio' and not $args{dash_mp4_audio}) { - if ($itag->{format} eq 'm4a') { - next; # skip m4a audio URLs + if ($resolution eq 'audio' and $args{prefer_m4a}) { + if ($itag->{format} ne 'm4a') { + next; # skip non-M4A audio URLs } } # Ignore segmented DASH URLs (they load pretty slow in mpv) - if (not $args{dash_segmented}) { + if (not $args{dash}) { next if ($entry->{url} =~ m{/api/manifest/dash/}); } @@ -215,9 +222,12 @@ Return the streaming URL which corresponds with the specified resolution. ( urls => \@streaming_urls, resolution => 'resolution_name', # from $obj->get_resolutions(), - dash => 1/0, # include or exclude DASH itags - dash_mp4_audio => 1/0, # include or exclude DASH videos with MP4 audio - dash_segmented => 1/0, # include or exclude segmented DASH videos + + hfr => 1/0, # include or exclude High Frame Rate videos + ignore_av1 => 1/0, # true to ignore videos in AV1 format + split => 1/0, # include or exclude split videos + m4a_audio => 1/0, # incldue or exclude M4A audio files + dash => 1/0, # include or exclude streams in DASH format ) =cut @@ -253,11 +263,50 @@ sub find_streaming_url { $found_resolution = $resolution; } - # Otherwise, find the best resolution available - if (not defined $streaming) { + state $resolutions = $self->get_resolutions(); + + # Find the nearest available resolution + if (defined($resolution) and not defined($streaming)) { + + my $end = $#{$resolutions} - 1; # -1 to ignore 'audio' + + foreach my $i (0 .. $end) { + if ($resolutions->[$i] eq $resolution) { + for (my $k = 1 ; ; ++$k) { + + if ($i + $k > $end and $i - $k < 0) { + last; + } + + if ($i + $k <= $end) { # nearest below - state $resolutions = $self->get_resolutions(); + my $res = $resolutions->[$i + $k]; + $streaming = $self->_find_streaming_url(%args, resolution => $res); + if (defined($streaming)) { + $found_resolution = $res; + last; + } + } + + if ($i - $k >= 0) { # nearest above + + my $res = $resolutions->[$i - $k]; + $streaming = $self->_find_streaming_url(%args, resolution => $res); + + if (defined($streaming)) { + $found_resolution = $res; + last; + } + } + } + last; + } + } + } + + # Otherwise, find the best resolution available + if (not defined $streaming) { foreach my $res (@{$resolutions}) { $streaming = $self->_find_streaming_url(%args, resolution => $res); diff --git a/lib/WWW/FairViewer/ParseJSON.pm b/lib/WWW/FairViewer/ParseJSON.pm index 4945a2a..6733eb0 100644 --- a/lib/WWW/FairViewer/ParseJSON.pm +++ b/lib/WWW/FairViewer/ParseJSON.pm @@ -23,6 +23,18 @@ Parse a JSON string and return a HASH ref. =cut +sub parse_utf8_json_string { + my ($self, $json) = @_; + + if (not defined($json) or $json eq '') { + return {}; + } + + require JSON; + my $hash = eval { JSON::from_json($json) }; + return $@ ? do { warn "[JSON]: $@\n"; {} } : $hash; +} + sub parse_json_string { my ($self, $json) = @_; diff --git a/lib/WWW/FairViewer/ParseXML.pm b/lib/WWW/FairViewer/ParseXML.pm index 733c2bc..9c4fa04 100644 --- a/lib/WWW/FairViewer/ParseXML.pm +++ b/lib/WWW/FairViewer/ParseXML.pm @@ -287,8 +287,11 @@ sub xml2hash { =head1 AUTHOR +Trizen, C<< >> + Jesus, C<< >> + =head1 SUPPORT You can find documentation for this module with the perldoc command. diff --git a/lib/WWW/FairViewer/PlaylistItems.pm b/lib/WWW/FairViewer/PlaylistItems.pm index 090a4b3..5555265 100644 --- a/lib/WWW/FairViewer/PlaylistItems.pm +++ b/lib/WWW/FairViewer/PlaylistItems.pm @@ -80,7 +80,13 @@ Get videos from a specific playlistID. sub videos_from_playlist_id { my ($self, $id) = @_; - $self->_get_results($self->_make_feed_url("playlists/$id")); + + if (my $results = $self->yt_playlist_videos($id)) { + return $results; + } + + my $url = $self->_make_feed_url("playlists/$id"); + $self->_get_results($url); } =head2 favorites($channel_id) diff --git a/lib/WWW/FairViewer/Playlists.pm b/lib/WWW/FairViewer/Playlists.pm index 01277c0..4294352 100644 --- a/lib/WWW/FairViewer/Playlists.pm +++ b/lib/WWW/FairViewer/Playlists.pm @@ -6,7 +6,7 @@ use warnings; =head1 NAME -WWW::FairViewer::Playlists - Fair playlists handle. +WWW::FairViewer::Playlists - YouTube playlists related mehods. =head1 SYNOPSIS @@ -25,7 +25,7 @@ sub _make_playlists_url { $opts{'part'} = 'snippet,contentDetails'; } - $self->_make_feed_url('playlists', %opts); + $self->_make_feed_url('playlists', %opts,); } sub get_playlist_id { @@ -60,7 +60,13 @@ Get and return playlists from a channel ID. sub playlists { my ($self, $channel_id) = @_; - $self->_get_results($self->_make_feed_url("channels/playlists/$channel_id")); + + if (my $results = $self->yt_channel_playlists($channel_id)) { + return $results; + } + + my $url = $self->_make_feed_url("channels/playlists/$channel_id"); + $self->_get_results($url); } =head2 playlists_from_username($username) diff --git a/lib/WWW/FairViewer/Search.pm b/lib/WWW/FairViewer/Search.pm index 7242637..68f4521 100644 --- a/lib/WWW/FairViewer/Search.pm +++ b/lib/WWW/FairViewer/Search.pm @@ -6,7 +6,7 @@ use warnings; =head1 NAME -WWW::FairViewer::Search - Search functions for Fair API v3 +WWW::FairViewer::Search - Search for stuff on YouTube =head1 SYNOPSIS @@ -85,16 +85,26 @@ sub search_for { # Search in a channel's videos if (defined(my $channel_id = $self->get_channelId)) { - my $url = $self->_make_feed_url("channels/search/$channel_id", q => $keywords,); + + $self->set_channelId(); # clear the channel ID + + if (my $results = $self->yt_channel_search($channel_id, q => $keywords, type => $type, %$args)) { + return $results; + } + + my $url = $self->_make_feed_url("channels/search/$channel_id", q => $keywords); return $self->_get_results($url); } + if (my $results = $self->yt_search(q => $keywords, type => $type, %$args)) { + return $results; + } + my $url = $self->_make_search_url( type => $type, q => $keywords, - %$args, + %$args ); - return $self->_get_results($url); } @@ -161,15 +171,12 @@ be set to a YouTube video ID. sub related_to_videoID { my ($self, $videoID) = @_; - my %info = $self->_get_video_info($videoID); - my $watch_next_response = $self->parse_json_string($info{watch_next_response}); + my $watch_next_response = $self->parse_json_string($self->_get_video_next_info($videoID) // return {results => []}); + my $related = eval { $watch_next_response->{contents}{twoColumnWatchNextResults}{secondaryResults}{secondaryResults}{results} } // return {results => []}; - #use Data::Dump qw(pp); - #pp $related; - my @results; foreach my $entry (@$related) { diff --git a/lib/WWW/FairViewer/Utils.pm b/lib/WWW/FairViewer/Utils.pm index 8cdcce3..1076af2 100644 --- a/lib/WWW/FairViewer/Utils.pm +++ b/lib/WWW/FairViewer/Utils.pm @@ -96,6 +96,9 @@ Returns time from seconds. sub format_time { my ($self, $sec) = @_; + + $sec //= 0; + $sec >= 3600 ? join q{:}, map { sprintf '%02d', $_ } $sec / 3600 % 24, $sec / 60 % 60, $sec % 60 : join q{:}, map { sprintf '%02d', $_ } $sec / 60 % 60, $sec % 60; @@ -133,6 +136,8 @@ Return string "04 May 2010" from "2010-05-04T00:25:55.000Z" sub format_date { my ($self, $date) = @_; + $date // return undef; + # 2010-05-04T00:25:55.000Z # to: 04 May 2010 @@ -158,6 +163,8 @@ Return the (approximated) age for a given date of the form "2010-05-04T00:25:55. sub date_to_age { my ($self, $date) = @_; + $date // return undef; + $date =~ m{^ (?\d{4}) - @@ -177,6 +184,21 @@ sub date_to_age { $year += 1900; $month += 1; + my %month_days = ( + 1 => 31, + 2 => 28, + 3 => 31, + 4 => 30, + 5 => 31, + 6 => 30, + 7 => 31, + 8 => 31, + 9 => 30, + 10 => 31, + 11 => 30, + 12 => 31, + ); + my $lambda = sub { if ($year == $+{year}) { @@ -192,6 +214,14 @@ sub date_to_age { } return join(' ', $day - $+{day}, 'days'); } + + if ($month - $+{month} == 1) { + my $day_diff = $+{day} - $day; + if ($day_diff > 0 and $day_diff < $month_days{$+{month} + 0}) { + return join(' ', $month_days{$+{month} + 0} - $day_diff, 'days'); + } + } + return join(' ', $month - $+{month}, 'months'); } @@ -227,7 +257,7 @@ sub has_entries { if (ref($result->{results}) eq 'HASH') { - foreach my $type (qw(comments videos playlists)) { + foreach my $type (qw(comments videos playlists entries)) { if (exists $result->{results}{$type}) { ref($result->{results}{$type}) eq 'ARRAY' or return 0; return (@{$result->{results}{$type}} > 0); @@ -252,15 +282,21 @@ sub has_entries { return 1; # maybe? } -=head2 normalize_video_title($title, $fat32safe) +=head2 normalize_filename($title, $fat32safe) Replace file-unsafe characters and trim spaces. =cut -sub normalize_video_title { +sub normalize_filename { my ($self, $title, $fat32safe) = @_; + state $unix_like = $^O =~ /^(?:linux|freebsd|openbsd)\z/i; + + if (not $fat32safe and not $unix_like) { + $fat32safe = 1; + } + if ($fat32safe) { $title =~ s/: / - /g; $title =~ tr{:"*/?\\|}{;'+%!%%}; # " @@ -270,7 +306,9 @@ sub normalize_video_title { $title =~ tr{/}{%}; } - join(q{ }, split(q{ }, $title)); + my $basename = join(q{ }, split(q{ }, $title)); + $basename = substr($basename, 0, 200); # make sure the filename is not too long + return $basename; } =head2 format_text(%opt) @@ -299,20 +337,32 @@ sub format_text { my $fat32safe = $opt{fat32safe}; my %special_tokens = ( - ID => sub { $self->get_video_id($info) }, - AUTHOR => sub { $self->get_channel_title($info) }, - CHANNELID => sub { $self->get_channel_id($info) }, - DEFINITION => sub { $self->get_definition($info) }, - DIMENSION => sub { $self->get_dimension($info) }, + ID => sub { $self->get_video_id($info) }, + AUTHOR => sub { $self->get_channel_title($info) }, + CHANNELID => sub { $self->get_channel_id($info) }, + DEFINITION => sub { $self->get_definition($info) }, + DIMENSION => sub { $self->get_dimension($info) }, + VIEWS => sub { $self->get_views($info) }, VIEWS_SHORT => sub { $self->get_views_approx($info) }, - LIKES => sub { $self->get_likes($info) }, - DISLIKES => sub { $self->get_dislikes($info) }, + + VIDEOS => sub { $self->set_thousands($self->get_channel_video_count($info)) }, + VIDEOS_SHORT => sub { $self->short_human_number($self->get_channel_video_count($info)) }, + + SUBS => sub { $self->get_channel_subscriber_count($info) }, + SUBS_SHORT => sub { $self->short_human_number($self->get_channel_subscriber_count($info)) }, + + ITEMS => sub { $self->set_thousands($self->get_playlist_item_count($info)) }, + ITEMS_SHORT => sub { $self->short_human_number($self->get_playlist_item_count($info)) }, + + LIKES => sub { $self->get_likes($info) }, + DISLIKES => sub { $self->get_dislikes($info) }, + COMMENTS => sub { $self->get_comments($info) }, DURATION => sub { $self->get_duration($info) }, TIME => sub { $self->get_time($info) }, TITLE => sub { $self->get_title($info) }, - FTITLE => sub { $self->normalize_video_title($self->get_title($info), $fat32safe) }, + FTITLE => sub { $self->normalize_filename($self->get_title($info), $fat32safe) }, CAPTION => sub { $self->get_caption($info) }, PUBLISHED => sub { $self->get_publication_date($info) }, AGE => sub { $self->get_publication_age($info) }, @@ -386,8 +436,8 @@ sub format_text { $text =~ s/$escapes_re/$special_escapes{$1}/g; $escape - ? $text =~ s/$tokens_re/\Q${\$special_tokens{$1}()}\E/gr - : $text =~ s/$tokens_re/${\$special_tokens{$1}()}/gr; + ? $text =~ s<$tokens_re><\Q${\($special_tokens{$1}() // '')}\E>gr + : $text =~ s<$tokens_re><${\($special_tokens{$1}() // '')}>gr; } =head2 set_thousands($num) @@ -487,13 +537,112 @@ sub get_description { $desc = HTML::Entities::decode_entities($desc); $desc =~ s/^\s+//; - if (not $desc =~ /\S/) { + if (not $desc =~ /\S/ or length($desc) < length($info->{description} // '')) { $desc = $info->{description} // ''; } ($desc =~ /\S/) ? $desc : 'No description available...'; } +sub read_lines_from_file { + my ($self, $file, $mode) = @_; + + $mode //= '<'; + + open(my $fh, $mode, $file) or return; + chomp(my @lines = <$fh>); + close $fh; + + my %seen; + + # Keep the most recent ones + @lines = reverse(@lines); + @lines = grep { !$seen{$_}++ } @lines; + + return @lines; +} + +sub read_channels_from_file { + my ($self, $file, $mode) = @_; + + $mode //= '<:utf8'; + + # Read channels and remove duplicates + my %channels = map { split(/ /, $_, 2) } $self->read_lines_from_file($file, $mode); + + # Filter valid channels and pair with channel ID with title + my @channels = map { [$_, $channels{$_}] } grep { defined($channels{$_}) } keys %channels; + + # Sort channels by channel name + @channels = sort { CORE::fc($a->[1]) cmp CORE::fc($b->[1]) } @channels; + + return @channels; +} + +sub get_local_playlist_filenames { + my ($self, $dir) = @_; + require Encode; + grep { -f $_ } sort { CORE::fc($a) cmp CORE::fc($b) } map { Encode::decode_utf8($_) } glob("$dir/*.dat"); +} + +sub make_local_playlist_filename { + my ($self, $title, $playlistID) = @_; + my $basename = $title . ' -- ' . $playlistID . '.txt'; + $basename = $self->normalize_filename($basename); + return $basename; +} + +sub local_playlist_snippet { + my ($self, $id) = @_; + + require File::Basename; + my $title = File::Basename::basename($id); + + $title =~ s/\.dat\z//; + $title =~ s/ -- PL[-\w]+\z//; + $title =~ s/_/ /g; + $title = ucfirst($title); + + require Storable; + my $entries = eval { Storable::retrieve($id) } // []; + + if (ref($entries) ne 'ARRAY') { + $entries = []; + } + + my $video_count = 0; + my $video_id = undef; + + if (@$entries) { + $video_id = $self->get_video_id($entries->[0]); + $video_count = scalar(@$entries); + } + + scalar { + author => "local", + authorId => "local", + description => $title, + playlistId => $id, + playlistThumbnail => (defined($video_id) ? "https://i.ytimg.com/vi/$video_id/mqdefault.jpg" : undef), + title => $title, + type => "playlist", + videoCount => $video_count, + }; +} + +sub local_channel_snippet { + my ($self, $id, $title) = @_; + + scalar { + author => $title, + authorId => $id, + type => "channel", + description => "", + subCount => undef, + videoCount => undef, + }; +} + =head2 get_title($info) Get title. @@ -545,7 +694,7 @@ sub get_thumbnail_url { $url = eval { $wanted[0]{url} } // return ''; } else { - warn "[!] Couldn't find thumbnail of type <<$type>>..."; + ## warn "[!] Couldn't find thumbnail of type <<$type>>..."; $url = eval { $thumbs[0]{url} } // return ''; } @@ -559,7 +708,7 @@ sub get_channel_title { my ($self, $info) = @_; #$info->{snippet}{channelTitle} || $self->get_channel_id($info); - $info->{author}; + $info->{author} // $info->{title}; } sub get_author { @@ -572,6 +721,31 @@ sub get_comment_id { $info->{commentId}; } +sub get_video_count { + my ($self, $info) = @_; + $info->{videoCount} // 0; +} + +sub get_subscriber_count { + my ($self, $info) = @_; + $info->{subCount} // 0; +} + +sub get_channel_subscriber_count { + my ($self, $info) = @_; + $info->{subCount} // 0; +} + +sub get_channel_video_count { + my ($self, $info) = @_; + $info->{videoCount} // 0; +} + +sub get_playlist_item_count { + my ($self, $info) = @_; + $info->{videoCount} // 0; +} + sub get_comment_content { my ($self, $info) = @_; $info->{content}; @@ -579,24 +753,23 @@ sub get_comment_content { sub get_id { my ($self, $info) = @_; - - #$info->{id}; $info->{videoId}; } -sub get_channel_id { +sub get_rating { my ($self, $info) = @_; + my $rating = $info->{rating} // return; + sprintf('%.2f', $rating); +} - #$info->{snippet}{resourceId}{channelId} // $info->{snippet}{channelId}; +sub get_channel_id { + my ($self, $info) = @_; $info->{authorId}; } sub get_category_id { my ($self, $info) = @_; - - #$info->{snippet}{resourceId}{categoryId} // $info->{snippet}{categoryId}; - #"unknown"; - $info->{genre} // 'Unknown'; + $info->{genre} // $info->{category} // 'Unknown'; } sub get_category_name { @@ -620,9 +793,7 @@ sub get_category_name { 29 => 'Nonprofits & Activism', }; - #$categories->{$self->get_category_id($info) // ''} // 'Unknown'; - - $info->{genre} // 'Unknown'; + $info->{genre} // $info->{category} // 'Unknown'; } sub get_publication_date { @@ -635,8 +806,80 @@ sub get_publication_date { require Encode; require Time::Piece; - my $time = Time::Piece->new($info->{published}); - Encode::decode_utf8($time->strftime("%d %B %Y")); + my $time; + + if (defined($info->{published})) { + $time = eval { Time::Piece->new($info->{published}) }; + } + elsif (defined($info->{publishDate})) { + if ($info->{publishDate} =~ /^[0-9]+\z/) { # time given as "%yyyy%mm%dd" (from hypervideo) + $time = eval { Time::Piece->strptime($info->{publishDate}, '%Y%m%d') }; + } + else { + $time = eval { Time::Piece->strptime($info->{publishDate}, '%Y-%m-%d') }; + } + } + + defined($time) ? Encode::decode_utf8($time->strftime("%d %B %Y")) : undef; +} + +sub get_publication_time { + my ($self, $info) = @_; + + require Time::Piece; + require Time::Seconds; + + if ($self->get_time($info) eq 'LIVE') { + my $time = $info->{timestamp} // Time::Piece->new(); + + if (ref($time) eq 'ARRAY') { + $time = bless($time, "Time::Piece"); + } + + return $time; + } + + if (defined($info->{publishedText})) { + + my $age = $info->{publishedText}; + my $t = $info->{timestamp} // Time::Piece->new(); + + if (ref($t) eq 'ARRAY') { + $t = bless($t, "Time::Piece"); + } + + if ($age =~ /^(\d+) sec/) { + $t -= $1; + } + + if ($age =~ /^(\d+) min/) { + $t -= $1 * Time::Seconds::ONE_MINUTE(); + } + + if ($age =~ /^(\d+) hour/) { + $t -= $1 * Time::Seconds::ONE_HOUR(); + } + + if ($age =~ /^(\d+) day/) { + $t -= $1 * Time::Seconds::ONE_DAY(); + } + + if ($age =~ /^(\d+) week/) { + $t -= $1 * Time::Seconds::ONE_WEEK(); + } + + if ($age =~ /^(\d+) month/) { + $t -= $1 * Time::Seconds::ONE_MONTH(); + } + + if ($age =~ /^(\d+) year/) { + $t -= $1 * Time::Seconds::ONE_YEAR(); + } + + return $t; + } + + return $self->get_publication_date($info); # should not happen } sub get_publication_age { @@ -674,22 +917,17 @@ sub get_publication_age_approx { sub get_duration { my ($self, $info) = @_; - - #$self->format_duration($info->{contentDetails}{duration}); - #$self->format_duration($info->{lengthSeconds}); $info->{lengthSeconds}; } sub get_time { my ($self, $info) = @_; - if ($info->{liveNow}) { + if ($info->{liveNow} and ($self->get_duration($info) || 0) == 0) { return 'LIVE'; } $self->format_time($self->get_duration($info)); - - #$self->format_time($self->get_duration($info)); } sub get_definition { @@ -721,39 +959,44 @@ sub get_views { $info->{viewCount} // 0; } -sub get_views_approx { - my ($self, $info) = @_; - my $views = $self->get_views($info); +sub short_human_number { + my ($self, $int) = @_; - if ($views < 1000) { - return $views; + if ($int < 1000) { + return $int; } - if ($views >= 10 * 1e9) { # ten billions - return sprintf("%dB", int($views / 1e9)); + if ($int >= 10 * 1e9) { # ten billions + return sprintf("%dB", int($int / 1e9)); } - if ($views >= 1e9) { # billions - return sprintf("%.2gB", $views / 1e9); + if ($int >= 1e9) { # billions + return sprintf("%.2gB", $int / 1e9); } - if ($views >= 10 * 1e6) { # ten millions - return sprintf("%dM", int($views / 1e6)); + if ($int >= 10 * 1e6) { # ten millions + return sprintf("%dM", int($int / 1e6)); } - if ($views >= 1e6) { # millions - return sprintf("%.2gM", $views / 1e6); + if ($int >= 1e6) { # millions + return sprintf("%.2gM", $int / 1e6); } - if ($views >= 10 * 1e3) { # ten thousands - return sprintf("%dK", int($views / 1e3)); + if ($int >= 10 * 1e3) { # ten thousands + return sprintf("%dK", int($int / 1e3)); } - if ($views >= 1e3) { # thousands - return sprintf("%.2gK", $views / 1e3); + if ($int >= 1e3) { # thousands + return sprintf("%.2gK", $int / 1e3); } - return $views; + return $int; +} + +sub get_views_approx { + my ($self, $info) = @_; + my $views = $self->get_views($info); + $self->short_human_number($views); } sub get_likes { diff --git a/lib/WWW/FairViewer/Videos.pm b/lib/WWW/FairViewer/Videos.pm index 4acd866..72ad523 100644 --- a/lib/WWW/FairViewer/Videos.pm +++ b/lib/WWW/FairViewer/Videos.pm @@ -149,7 +149,7 @@ When C<$part> is C, it defaults to I. =cut -sub video_details { +sub _invidious_video_details { my ($self, $id, $fields) = @_; $fields //= $self->basic_video_info_fields; @@ -159,24 +159,92 @@ sub video_details { return $info; } + return; +} + +sub _ytdl_video_details { + my ($self, $id) = @_; + $self->_info_from_ytdl($id); +} + +sub _fallback_video_details { + my ($self, $id, $fields) = @_; + + if ($self->get_debug) { + say STDERR ":: Extracting video info with hypervideo..."; + } + + my $info = $self->_ytdl_video_details($id); + + if (defined($info) and ref($info) eq 'HASH') { + return scalar { + + title => $info->{fulltitle} // $info->{title}, + videoId => $id, + + videoThumbnails => [ + map { + scalar { + quality => 'medium', + url => $_->{url}, + width => $_->{width}, + height => $_->{height}, + } + } @{$info->{thumbnails}} + ], + + liveNow => ($info->{is_live} ? 1 : 0), + description => $info->{description}, + lengthSeconds => $info->{duration}, + + likeCount => $info->{like_count}, + dislikeCount => $info->{dislike_count}, + + category => eval { $info->{categories}[0] } // $info->{category}, + publishDate => $info->{upload_date}, + + keywords => $info->{tags}, + viewCount => $info->{view_count}, + + author => $info->{channel}, + authorId => $info->{channel_id} // $info->{uploader_id}, + rating => $info->{average_rating}, + }; + } + else { + #$info = $self->_invidious_video_details($id, $fields); # too slow + } + + return {}; +} + +sub video_details { + my ($self, $id, $fields) = @_; + if ($self->get_debug) { say STDERR ":: Extracting video info using the fallback method..."; } - # Fallback using the `get_video_info` URL my %video_info = $self->_get_video_info($id); - my $video = $self->parse_json_string($video_info{player_response} // return); + my $video = $self->parse_json_string($video_info{player_response} // return $self->_fallback_video_details($id, $fields)); + + my $videoDetails = {}; + my $microformat = {}; if (exists $video->{videoDetails}) { - $video = $video->{videoDetails}; + $videoDetails = $video->{videoDetails}; } else { - return; + return $self->_fallback_video_details($id, $fields); + } + + if (exists $video->{microformat}) { + $microformat = eval { $video->{microformat}{playerMicroformatRenderer} } // {}; } my %details = ( - title => $video->{title}, - videoId => $video->{videoId}, + title => eval { $microformat->{title}{simpleText} } // $videoDetails->{title}, + videoId => $videoDetails->{videoId}, videoThumbnails => [ map { @@ -186,19 +254,22 @@ sub video_details { width => $_->{width}, height => $_->{height}, } - } @{$video->{thumbnail}{thumbnails}} + } @{$videoDetails->{thumbnail}{thumbnails}} ], - liveNow => $video->{isLiveContent}, - description => $video->{shortDescription}, - lengthSeconds => $video->{lengthSeconds}, + liveNow => ($videoDetails->{isLiveContent} || (($videoDetails->{lengthSeconds} || 0) == 0)), + description => eval { $microformat->{description}{simpleText} } // $videoDetails->{shortDescription}, + lengthSeconds => $videoDetails->{lengthSeconds} // $microformat->{lengthSeconds}, - keywords => $video->{keywords}, - viewCount => $video->{viewCount}, + category => $microformat->{category}, + publishDate => $microformat->{publishDate}, - author => $video->{author}, - authorId => $video->{channelId}, - rating => $video->{averageRating}, + keywords => $videoDetails->{keywords}, + viewCount => $videoDetails->{viewCount} // $microformat->{viewCount}, + + author => $videoDetails->{author} // $microformat->{ownerChannelName}, + authorId => $videoDetails->{channelId} // $microformat->{externalChannelId}, + rating => $videoDetails->{averageRating}, ); return \%details; @@ -218,6 +289,8 @@ with a HASH ref for each result. An example of the item array's content are show =head1 AUTHOR +Trizen, C<< >> + Jesus, C<< >> diff --git a/share/gtk-fair-viewer.glade b/share/gtk-fair-viewer.glade index d8d22d4..2c3fe04 100644 --- a/share/gtk-fair-viewer.glade +++ b/share/gtk-fair-viewer.glade @@ -1,9 +1,9 @@ - @@ -29,7 +28,7 @@ Author: Jesus E. https://framagit.org/heckyel - + 1 @@ -76,6 +75,16 @@ Author: Jesus E. https://framagit.org/heckyel False gtk-missing-image + + True + False + gtk-missing-image + + + True + False + gtk-index + True False @@ -94,6 +103,7 @@ Author: Jesus E. https://framagit.org/heckyel Videos True False + List latest YouTube videos on this channel image17 False @@ -104,20 +114,32 @@ Author: Jesus E. https://framagit.org/heckyel Playlists True False + List YouTube playlists created by this channel image14 False + + + (Un)subscribe + True + False + Subscribe / unsubscribe from this channel + icon_from_pixbuf + False + + + gtk-remove True False - Remove the selected user from list... + Remove the selected channel from the list True True - + @@ -158,7 +180,7 @@ Author: Jesus E. https://framagit.org/heckyel True False - gtk-refresh + gtk-index True @@ -175,6 +197,11 @@ Author: Jesus E. https://framagit.org/heckyel False mail-reply-all + + True + False + gtk-index + @@ -287,6 +314,29 @@ Author: Jesus E. https://framagit.org/heckyel + + + Watched videos + True + False + Display the list of watched videos + image9 + False + + + + + + Subscription videos + True + True + False + Display local subscription videos + image10 + False + + + CLI Fair Viewer @@ -295,13 +345,12 @@ Author: Jesus E. https://framagit.org/heckyel Search and play videos in command line interface (CTRL+Y) terminal_icon2 False - + Login to YouTube - True False Login to YouTube using OAuth 2.0 authentication. image6 @@ -309,17 +358,6 @@ Author: Jesus E. https://framagit.org/heckyel - - - Warnings log - True - False - Show the warnings window - image76 - False - - - gtk-preferences @@ -431,6 +469,17 @@ Author: Jesus E. https://framagit.org/heckyel + + + Warnings + True + False + Show the warnings window + image76 + False + + + gtk-about @@ -452,6 +501,49 @@ Author: Jesus E. https://framagit.org/heckyel 0 + + + True + False + + + False + + + False + False + end + 0 + + + + + True + True + entrybuffer1 + + True + Search for YouTube videos... + False + gtk-find + False + + + + + + True + True + 1 + + + + + False + False + 1 + + True @@ -533,49 +625,6 @@ Author: Jesus E. https://framagit.org/heckyel True False vertical - - - True - False - - - True - True - True - True - entrybuffer1 - - True - False - gtk-find - False - - - - - True - True - end - 0 - - - - - False - - - False - False - 1 - - - - - False - False - 0 - - True @@ -632,7 +681,7 @@ Author: Jesus E. https://framagit.org/heckyel - Favorited videos + Favorites True True True @@ -1014,7 +1063,6 @@ Author: Jesus E. https://framagit.org/heckyel True False short – less than 4 minutes long -medium – 4 to 20 minutes (inclusive) long – longer than 20 minutes 0 @@ -1388,7 +1436,7 @@ Unless the author name is valid, this field is ignored. True True False - Show thumbnails for videos in search results. + Show thumbnails for results. True @@ -1436,14 +1484,13 @@ Unless the author name is valid, this field is ignored. True True False - Support for videos in DASH format. -When disabled, streams in DASH format will be ignored if there exists an alternative. + Include or exclude streams in DASH format. True - True - True + False + False 3 @@ -1453,14 +1500,14 @@ When disabled, streams in DASH format will be ignored if there exists an alterna True True False - Clear previous search results after each new search. + Clear the search list after each new search. True True True - 5 + 4 @@ -1554,6 +1601,7 @@ When the specified resolution is not found, the best available resolution is use 480p 360p 240p + 144p @@ -1922,6 +1970,7 @@ When the specified resolution is not found, the best available resolution is use + True True @@ -1937,7 +1986,7 @@ When the specified resolution is not found, the best available resolution is use True True liststore6 - + @@ -1954,7 +2003,7 @@ When the specified resolution is not found, the best available resolution is use - Top + Playlist @@ -1972,101 +2021,16 @@ When the specified resolution is not found, the best available resolution is use - + + Reverse playlist entries True True - - - True - False - vertical - - - True - False - 0 - none - - - True - False - 12 - - - True - True - - False - False - - - - - - - True - False - <b>Region ID</b> - True - - - - - True - True - 0 - - - - - True - False - 0 - none - - - True - False - 12 - - - True - True - - False - False - - - - - - - True - False - <b>Category ID:</b> - True - - - - - True - True - 1 - - - - - - - True - False - Options - - + False + True False - False + True 1 @@ -2083,7 +2047,7 @@ When the specified resolution is not found, the best available resolution is use True False - Tops + Playlists 4 @@ -2130,7 +2094,7 @@ When the specified resolution is not found, the best available resolution is use True True - 1 + 2 @@ -2144,17 +2108,18 @@ When the specified resolution is not found, the best available resolution is use __MAIN__ GTK Fair Viewer -Copyright © 2010-2020 Trizen -Copyright © 2020 Jesús E. +Copyright © 2010-2021 Trizen +Copyright © 2020-2021 Jesús E. + Written in Perl, Gtk3 and Glade. - https://framagit.org/heckyel/fair-viewer - https://framagit.org/heckyel/fair-viewer + https://git.sr.ht/~heckyel/fair-viewer + https://git.sr.ht/~heckyel/fair-viewer Trizen https://github.com/trizen Ovidiu D. Nițan <nitanovidiu@gmail.com> Jookia https://github.com/Jookia Andreas Hrubak https://github.com/bAndie91 -Jesús E. https://framagit.org/heckyel -and others... https://framagit.org/heckyel/fair-viewer/-/graphs/master +Jesús E. https://git.sr.ht/~heckyel +and others... PosixRU (main logo) http://zenway.ru/page/gtk-youtube-viewer image-missing artistic @@ -2184,7 +2149,7 @@ and others... https://framagit.org/heckyel/fair-viewer/-/graphs/master False - Video details + Extra details True center-on-parent 400 @@ -3398,9 +3363,10 @@ and others... https://framagit.org/heckyel/fair-viewer/-/graphs/master5 Warnings log center-on-parent - 320 - 260 + 400 + 300 dialog + __MAIN__ diff --git a/utils/auto_perltidy.sh b/utils/auto_perltidy.sh index 866dcfa..4c6bd7b 100755 --- a/utils/auto_perltidy.sh +++ b/utils/auto_perltidy.sh @@ -3,4 +3,4 @@ alias perltidy='perltidy -utf8 -l=127 -f -kbl=1 -bbb -bbc -bbs -b -ple -bt=2 -pt=2 -sbt=2 -bvt=0 -sbvt=1 -cti=1 -bar -lp -anl'; which perltidy; cd ..; -for i in $(git status | grep '^[[:cntrl:]]*modified:' | egrep 'bin/|\.(pm|t)$' | perl -nE 'say +(split)[-1]'); do echo $i; perltidy -b $i; done +for i in $(git status | grep '^[[:cntrl:]]*modified:' | grep -E 'bin/|\.(pm|t)$' | perl -nE 'say +(split)[-1]'); do echo "$i"; perltidy -b "$i"; done diff --git a/utils/bak_cleaner.sh b/utils/bak_cleaner.sh index cb8340e..3adb916 100755 --- a/utils/bak_cleaner.sh +++ b/utils/bak_cleaner.sh @@ -1,3 +1,3 @@ #!/bin/sh -for i in $(git status | grep \.bak$ | perl -nE 'say +(split)[-1]'); do echo $i; rm $i; done +for i in $(git status | grep \.bak$ | perl -nE 'say +(split)[-1]'); do echo "$i"; rm "$i"; done -- cgit v1.2.3