From 353ee7b23e63bde1d6b91ac5898a64960a32850d Mon Sep 17 00:00:00 2001 From: trizen Date: Fri, 30 Oct 2020 18:25:50 +0200 Subject: [PATCH] Added files. --- Build.PL | 94 + Changes | 10 + LICENSE | 201 + MANIFEST | 39 + MANIFEST.SKIP | 79 + Makefile.PL | 35 + README.md | 140 + bin/gtk-pipe-viewer | 3652 +++++++++++++++++ bin/pipe-viewer | 4532 ++++++++++++++++++++++ lib/WWW/PipeViewer.pm | 1294 ++++++ lib/WWW/PipeViewer/Activities.pm | 93 + lib/WWW/PipeViewer/Authentication.pm | 216 ++ lib/WWW/PipeViewer/Channels.pm | 214 + lib/WWW/PipeViewer/CommentThreads.pm | 98 + lib/WWW/PipeViewer/GetCaption.pm | 252 ++ lib/WWW/PipeViewer/GuideCategories.pm | 85 + lib/WWW/PipeViewer/Itags.pm | 299 ++ lib/WWW/PipeViewer/ParseJSON.pm | 88 + lib/WWW/PipeViewer/ParseXML.pm | 311 ++ lib/WWW/PipeViewer/PlaylistItems.pm | 146 + lib/WWW/PipeViewer/Playlists.pm | 116 + lib/WWW/PipeViewer/RegularExpressions.pm | 89 + lib/WWW/PipeViewer/Search.pm | 498 +++ lib/WWW/PipeViewer/Subscriptions.pm | 272 ++ lib/WWW/PipeViewer/Utils.pm | 863 ++++ lib/WWW/PipeViewer/VideoCategories.pm | 63 + lib/WWW/PipeViewer/Videos.pm | 243 ++ share/gtk-pipe-viewer.desktop | 10 + share/gtk-pipe-viewer.glade | 3447 ++++++++++++++++ share/icons/default_thumb.jpg | Bin 0 -> 2310 bytes share/icons/feed.png | Bin 0 -> 901 bytes share/icons/feed_gray.png | Bin 0 -> 2161 bytes share/icons/gtk-pipe-viewer.png | Bin 0 -> 121443 bytes share/icons/spinner.gif | Bin 0 -> 1570 bytes share/icons/user.png | Bin 0 -> 4065 bytes t/00-load.t | 10 + t/kwalitee.t | 20 + t/pod.t | 12 + 38 files changed, 17521 insertions(+) create mode 100644 Build.PL create mode 100644 Changes create mode 100644 LICENSE create mode 100644 MANIFEST create mode 100644 MANIFEST.SKIP create mode 100644 Makefile.PL create mode 100644 README.md create mode 100755 bin/gtk-pipe-viewer create mode 100755 bin/pipe-viewer create mode 100644 lib/WWW/PipeViewer.pm create mode 100644 lib/WWW/PipeViewer/Activities.pm create mode 100644 lib/WWW/PipeViewer/Authentication.pm create mode 100644 lib/WWW/PipeViewer/Channels.pm create mode 100644 lib/WWW/PipeViewer/CommentThreads.pm create mode 100644 lib/WWW/PipeViewer/GetCaption.pm create mode 100644 lib/WWW/PipeViewer/GuideCategories.pm create mode 100644 lib/WWW/PipeViewer/Itags.pm create mode 100644 lib/WWW/PipeViewer/ParseJSON.pm create mode 100644 lib/WWW/PipeViewer/ParseXML.pm create mode 100644 lib/WWW/PipeViewer/PlaylistItems.pm create mode 100644 lib/WWW/PipeViewer/Playlists.pm create mode 100644 lib/WWW/PipeViewer/RegularExpressions.pm create mode 100644 lib/WWW/PipeViewer/Search.pm create mode 100644 lib/WWW/PipeViewer/Subscriptions.pm create mode 100644 lib/WWW/PipeViewer/Utils.pm create mode 100644 lib/WWW/PipeViewer/VideoCategories.pm create mode 100644 lib/WWW/PipeViewer/Videos.pm create mode 100644 share/gtk-pipe-viewer.desktop create mode 100644 share/gtk-pipe-viewer.glade create mode 100644 share/icons/default_thumb.jpg create mode 100644 share/icons/feed.png create mode 100644 share/icons/feed_gray.png create mode 100644 share/icons/gtk-pipe-viewer.png create mode 100644 share/icons/spinner.gif create mode 100644 share/icons/user.png create mode 100644 t/00-load.t create mode 100644 t/kwalitee.t create mode 100644 t/pod.t diff --git a/Build.PL b/Build.PL new file mode 100644 index 0000000..8a0bfcd --- /dev/null +++ b/Build.PL @@ -0,0 +1,94 @@ + +use utf8; +use 5.010; +use strict; +use warnings; +use Module::Build; + +my $gtk = grep { /^--?gtk3?\z/ } @ARGV; + +my $builder = Module::Build->new( + + module_name => 'WWW::PipeViewer', + license => 'perl', + dist_author => q{Trizen }, + dist_version_from => 'lib/WWW/PipeViewer.pm', + release_status => 'stable', + + build_requires => { + 'Test::More' => 0, + }, + + extra_manify_args => { utf8 => 1 }, + + configure_requires => { + 'Module::Build' => 0, + }, + + get_options => { + 'gtk3' => { + type => '!', + store => \$gtk, + }, + }, + + requires => { + 'perl' => 5.016, + 'Data::Dump' => 0, + 'File::Spec' => 0, + 'File::Spec::Functions' => 0, + 'File::Path' => 0, + 'Getopt::Long' => 0, + 'HTTP::Request' => 0, + 'JSON' => 0, + 'Encode' => 0, + 'Memoize' => 0, + 'MIME::Base64' => 0, + 'List::Util' => 0, + 'LWP::UserAgent' => 0, + 'LWP::Protocol::https' => 0, + 'Term::ANSIColor' => 0, + 'Term::ReadLine' => 0, + 'Text::ParseWords' => 0, + 'Text::Wrap' => 0, + 'URI::Escape' => 0, + + $gtk + ? ( + 'Gtk3' => 0, + 'File::ShareDir' => 0, + 'Storable' => 0, + 'Digest::MD5' => 0, + ) + : (), + }, + + recommends => { + 'LWP::UserAgent::Cached' => 0, # 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 + }, + + 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-PipeViewer-*'], + create_makefile_pl => 'traditional', +); + +$builder->script_files( + ['bin/pipe-viewer', + ($gtk ? ('bin/gtk-pipe-viewer') : ()), + ] + ); + +$builder->share_dir('share') if $gtk; +$builder->create_build_script(); diff --git a/Changes b/Changes new file mode 100644 index 0000000..5dcd73d --- /dev/null +++ b/Changes @@ -0,0 +1,10 @@ +# Revision history for pipe-viewer. + +# For all changes, check out the release notes at: +# https://github.com/trizen/pipe-viewer/releases + +[CHANGELOG] + +Version 0.0.1 + +- To be released soon. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..21840f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + The Artistic License 2.0 + + Copyright (c) 2000-2006, The Perl Foundation. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +Preamble + +This license establishes the terms under which a given free software +Package may be copied, modified, distributed, and/or redistributed. +The intent is that the Copyright Holder maintains some artistic +control over the development of that Package while still keeping the +Package available as open source and free software. + +You are always permitted to make arrangements wholly outside of this +license directly with the Copyright Holder of a given Package. If the +terms of this license do not permit the full use that you propose to +make of the Package, you should contact the Copyright Holder and seek +a different licensing arrangement. + +Definitions + + "Copyright Holder" means the individual(s) or organization(s) + named in the copyright notice for the entire Package. + + "Contributor" means any party that has contributed code or other + material to the Package, in accordance with the Copyright Holder's + procedures. + + "You" and "your" means any person who would like to copy, + distribute, or modify the Package. + + "Package" means the collection of files distributed by the + Copyright Holder, and derivatives of that collection and/or of + those files. A given Package may consist of either the Standard + Version, or a Modified Version. + + "Distribute" means providing a copy of the Package or making it + accessible to anyone else, or in the case of a company or + organization, to others outside of your company or organization. + + "Distributor Fee" means any fee that you charge for Distributing + this Package or providing support for this Package to another + party. It does not mean licensing fees. + + "Standard Version" refers to the Package if it has not been + modified, or has been modified only in ways explicitly requested + by the Copyright Holder. + + "Modified Version" means the Package, if it has been changed, and + such changes were not explicitly requested by the Copyright + Holder. + + "Original License" means this Artistic License as Distributed with + the Standard Version of the Package, in its current version or as + it may be modified by The Perl Foundation in the future. + + "Source" form means the source code, documentation source, and + configuration files for the Package. + + "Compiled" form means the compiled bytecode, object code, binary, + or any other form resulting from mechanical transformation or + translation of the Source form. + + +Permission for Use and Modification Without Distribution + +(1) You are permitted to use the Standard Version and create and use +Modified Versions for any purpose without restriction, provided that +you do not Distribute the Modified Version. + + +Permissions for Redistribution of the Standard Version + +(2) You may Distribute verbatim copies of the Source form of the +Standard Version of this Package in any medium without restriction, +either gratis or for a Distributor Fee, provided that you duplicate +all of the original copyright notices and associated disclaimers. At +your discretion, such verbatim copies may or may not include a +Compiled form of the Package. + +(3) You may apply any bug fixes, portability changes, and other +modifications made available from the Copyright Holder. The resulting +Package will still be considered the Standard Version, and as such +will be subject to the Original License. + + +Distribution of Modified Versions of the Package as Source + +(4) You may Distribute your Modified Version as Source (either gratis +or for a Distributor Fee, and with or without a Compiled form of the +Modified Version) provided that you clearly document how it differs +from the Standard Version, including, but not limited to, documenting +any non-standard features, executables, or modules, and provided that +you do at least ONE of the following: + + (a) make the Modified Version available to the Copyright Holder + of the Standard Version, under the Original License, so that the + Copyright Holder may include your modifications in the Standard + Version. + + (b) ensure that installation of your Modified Version does not + prevent the user installing or running the Standard Version. In + addition, the Modified Version must bear a name that is different + from the name of the Standard Version. + + (c) allow anyone who receives a copy of the Modified Version to + make the Source form of the Modified Version available to others + under + + (i) the Original License or + + (ii) a license that permits the licensee to freely copy, + modify and redistribute the Modified Version using the same + licensing terms that apply to the copy that the licensee + received, and requires that the Source form of the Modified + Version, and of any works derived from it, be made freely + available in that license fees are prohibited but Distributor + Fees are allowed. + + +Distribution of Compiled Forms of the Standard Version +or Modified Versions without the Source + +(5) You may Distribute Compiled forms of the Standard Version without +the Source, provided that you include complete instructions on how to +get the Source of the Standard Version. Such instructions must be +valid at the time of your distribution. If these instructions, at any +time while you are carrying out such distribution, become invalid, you +must provide new instructions on demand or cease further distribution. +If you provide valid instructions or cease distribution within thirty +days after you become aware that the instructions are invalid, then +you do not forfeit any of your rights under this license. + +(6) You may Distribute a Modified Version in Compiled form without +the Source, provided that you comply with Section 4 with respect to +the Source of the Modified Version. + + +Aggregating or Linking the Package + +(7) You may aggregate the Package (either the Standard Version or +Modified Version) with other packages and Distribute the resulting +aggregation provided that you do not charge a licensing fee for the +Package. Distributor Fees are permitted, and licensing fees for other +components in the aggregation are permitted. The terms of this license +apply to the use and Distribution of the Standard or Modified Versions +as included in the aggregation. + +(8) You are permitted to link Modified and Standard Versions with +other works, to embed the Package in a larger work of your own, or to +build stand-alone binary or bytecode versions of applications that +include the Package, and Distribute the result without restriction, +provided the result does not expose a direct interface to the Package. + + +Items That are Not Considered Part of a Modified Version + +(9) Works (including, but not limited to, modules and scripts) that +merely extend or make use of the Package, do not, by themselves, cause +the Package to be a Modified Version. In addition, such works are not +considered parts of the Package itself, and are not subject to the +terms of this license. + + +General Provisions + +(10) Any use, modification, and distribution of the Standard or +Modified Versions is governed by this Artistic License. By using, +modifying or distributing the Package, you accept this license. Do not +use, modify, or distribute the Package, if you do not accept this +license. + +(11) If your Modified Version has been derived from a Modified +Version made by someone other than you, you are nevertheless required +to ensure that your Modified Version complies with the requirements of +this license. + +(12) This license does not grant you the right to use any trademark, +service mark, tradename, or logo of the Copyright Holder. + +(13) This license includes the non-exclusive, worldwide, +free-of-charge patent license to make, have made, use, offer to sell, +sell, import and otherwise transfer the Package with respect to any +patent claims licensable by the Copyright Holder that are necessarily +infringed by the Package. If you institute patent litigation +(including a cross-claim or counterclaim) against any party alleging +that the Package constitutes direct or contributory patent +infringement, then this Artistic License to you shall terminate on the +date that such litigation is filed. + +(14) Disclaimer of Warranty: +THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS +IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR +NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL +LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR 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. diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..27c52e4 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,39 @@ +bin/gtk-pipe-viewer +bin/pipe-viewer +Build.PL +Changes +lib/WWW/PipeViewer.pm +lib/WWW/PipeViewer/Activities.pm +lib/WWW/PipeViewer/Authentication.pm +lib/WWW/PipeViewer/Channels.pm +lib/WWW/PipeViewer/CommentThreads.pm +lib/WWW/PipeViewer/GetCaption.pm +lib/WWW/PipeViewer/GuideCategories.pm +lib/WWW/PipeViewer/Itags.pm +lib/WWW/PipeViewer/ParseJSON.pm +lib/WWW/PipeViewer/ParseXML.pm +lib/WWW/PipeViewer/PlaylistItems.pm +lib/WWW/PipeViewer/Playlists.pm +lib/WWW/PipeViewer/RegularExpressions.pm +lib/WWW/PipeViewer/Search.pm +lib/WWW/PipeViewer/Subscriptions.pm +lib/WWW/PipeViewer/Utils.pm +lib/WWW/PipeViewer/VideoCategories.pm +lib/WWW/PipeViewer/Videos.pm +LICENSE +Makefile.PL +MANIFEST This list of files +META.json +META.yml +README.md +share/gtk-pipe-viewer.desktop +share/gtk-pipe-viewer.glade +share/icons/default_thumb.jpg +share/icons/feed.png +share/icons/feed_gray.png +share/icons/gtk-pipe-viewer.png +share/icons/spinner.gif +share/icons/user.png +t/00-load.t +t/kwalitee.t +t/pod.t diff --git a/MANIFEST.SKIP b/MANIFEST.SKIP new file mode 100644 index 0000000..6d94edc --- /dev/null +++ b/MANIFEST.SKIP @@ -0,0 +1,79 @@ + +#!start included /usr/share/perl5/core_perl/ExtUtils/MANIFEST.SKIP +# Avoid version control files. +\bRCS\b +\bCVS\b +\bSCCS\b +,v$ +\B\.svn\b +\B\.git\b +\B\.gitignore\b +\b_darcs\b +\B\.cvsignore$ + +# Avoid VMS specific MakeMaker generated files +\bDescrip.MMS$ +\bDESCRIP.MMS$ +\bdescrip.mms$ + +# Avoid Makemaker generated and utility files. +\bMANIFEST\.bak +\bMakefile$ +\bblib/ +\bMakeMaker-\d +\bpm_to_blib\.ts$ +\bpm_to_blib$ +\bblibdirs\.ts$ # 6.18 through 6.25 generated this + +# Avoid Module::Build generated and utility files. +\bBuild$ +\b_build/ +\bBuild.bat$ +\bBuild.COM$ +\bBUILD.COM$ +\bbuild.com$ + +# Other files +.github/FUNDING.yml +bin/inv.json +bin/yv.json + +# Avoid temp and backup files. +~$ +\.old$ +\#$ +\b\.# +\.bak$ +\.tmp$ +\.# +\.rej$ + +# Avoid OS-specific files/dirs +# Mac OSX metadata +\B\.DS_Store +# Mac OSX SMB mount metadata files +\B\._ + +# Avoid Devel::Cover and Devel::CoverX::Covered files. +\bcover_db\b +\bcovered\b + +# Avoid MYMETA files +^MYMETA\. +#!end included /usr/share/perl5/core_perl/ExtUtils/MANIFEST.SKIP + +# Avoid configuration metadata file +^MYMETA\. + +# Avoid Module::Build generated and utility files. +\bBuild$ +\bBuild.bat$ +\b_build +\bBuild.COM$ +\bBUILD.COM$ +\bbuild.com$ +^MANIFEST\.SKIP + +# Avoid archives of this distribution +\bWWW-PipeViewer-[\d\.\_]+ +WWW-PipeViewer-* diff --git a/Makefile.PL b/Makefile.PL new file mode 100644 index 0000000..5347b20 --- /dev/null +++ b/Makefile.PL @@ -0,0 +1,35 @@ +# Note: this file was auto-generated by Module::Build::Compat version 0.4231 +require 5.016; +use ExtUtils::MakeMaker; +WriteMakefile +( + 'NAME' => 'WWW::PipeViewer', + 'VERSION_FROM' => 'lib/WWW/PipeViewer.pm', + 'PREREQ_PM' => { + '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, + 'Test::More' => 0, + 'Text::ParseWords' => 0, + 'Text::Wrap' => 0, + 'URI::Escape' => 0 + }, + 'INSTALLDIRS' => 'site', + 'EXE_FILES' => [ + 'bin/pipe-viewer' + ], + 'PL_FILES' => {} +) +; diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1f3267 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +## pipe-viewer + +A lightweight application (fork of [straw-viewer](https://github.com/trizen/straw-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/). + +The goal of this fork is to parse the YouTube website directly, removing the dependency on invidious instances. + +### pipe-viewer + +* command-line interface to YouTube. + +![pipe-viewer](https://user-images.githubusercontent.com/614513/73046877-5cae1200-3e7c-11ea-8ab3-f8c444f88b30.png) + +### gtk-pipe-viewer + +* GTK+ interface to YouTube. + +![gtk-pipe-viewer](https://user-images.githubusercontent.com/614513/84770876-11d69780-afe1-11ea-96f7-5d426dc865e5.png) + + +### STATUS + +The project is in its early stages of development and some features are not implemented yet. + +Currently, only the searching for videos uses the YouTube website directly. + + +### AVAILABILITY + +* Arch Linux (AUR): https://aur.archlinux.org/packages/pipe-viewer-git/ + + +### INSTALLATION + +To install `pipe-viewer`, run: + +```console + perl Build.PL + sudo ./Build installdeps + sudo ./Build install +``` + +To install `gtk-pipe-viewer` along with `pipe-viewer`, run: + +```console + perl Build.PL --gtk + sudo ./Build installdeps + sudo ./Build install +``` + + +### TRY + +For trying the latest commit of `pipe-viewer`, without installing it, execute the following commands: + +```console + cd /tmp + wget https://github.com/trizen/pipe-viewer/archive/master.zip -O pipe-viewer-master.zip + unzip -n pipe-viewer-master.zip + cd pipe-viewer-master/bin + perl -pi -ne 's{DEVEL = 0}{DEVEL = 1}' {gtk-,}pipe-viewer + ./pipe-viewer +``` + + +### DEPENDENCIES + +#### For pipe-viewer: + +* [libwww-perl](https://metacpan.org/release/libwww-perl) +* [LWP::Protocol::https](https://metacpan.org/release/LWP-Protocol-https) +* [Data::Dump](https://metacpan.org/release/Data-Dump) +* [JSON](https://metacpan.org/release/JSON) + +#### For gtk-pipe-viewer: + +* [Gtk3](https://metacpan.org/release/Gtk3) +* [File::ShareDir](https://metacpan.org/release/File-ShareDir) +* \+ the dependencies required by pipe-viewer. + +#### Build dependencies: + +* [Module::Build](https://metacpan.org/pod/Module::Build) + +#### 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) +* 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) + + +### PACKAGING + +To package this application, run the following commands: + +```console + perl Build.PL --destdir "/my/package/path" --installdirs vendor [--gtk] + ./Build test + ./Build install --install_path script=/usr/bin +``` + +### 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/): + +```console + pipe-viewer --api=invidious.tube +``` + +To make the change permanent, set in the configuration file: + +```perl + api_host => "invidious.tube", +``` + +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/ + + +### SUPPORT AND DOCUMENTATION + +After installing, you can find documentation with the following commands: + + man pipe-viewer + perldoc WWW::PipeViewer + +### LICENSE AND COPYRIGHT + +Copyright (C) 2012-2020 Trizen + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See http://dev.perl.org/licenses/ for more information. diff --git a/bin/gtk-pipe-viewer b/bin/gtk-pipe-viewer new file mode 100755 index 0000000..cc45c75 --- /dev/null +++ b/bin/gtk-pipe-viewer @@ -0,0 +1,3652 @@ +#!/usr/bin/perl + +# Copyright (C) 2010-2020 Trizen . +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of either: the GNU General Public License as published +# by the Free Software Foundation; or the Artistic License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# +# See https://dev.perl.org/licenses/ for more information. +# +#------------------------------------------------------- +# GTK Pipe Viewer +# Fork: 30 October 2020 +# Edit: 30 October 2020 +# https://github.com/trizen/pipe-viewer +#------------------------------------------------------- + +# This is a fork of youtube-viewer: +# https://github.com/trizen/youtube-viewer + +use utf8; +use 5.016; + +use warnings; +no warnings 'once'; + +my $DEVEL; # true in devel mode +use if ($DEVEL = 1), lib => qw(../lib); # devel only + +use WWW::PipeViewer v0.0.1; +use WWW::PipeViewer::RegularExpressions; + +use Gtk3 qw(-init); +use File::Spec::Functions qw( + rel2abs + catdir + catfile + curdir + updir + path + tmpdir + file_name_is_absolute + ); + +binmode(STDOUT, ':utf8'); + +my $appname = 'GTK+ Pipe Viewer'; +my $version = $WWW::PipeViewer::VERSION; + +# Share directory +my $share_dir = + ($DEVEL and -d "../share") + ? '../share' + : do { require File::ShareDir; File::ShareDir::dist_dir('WWW-PipeViewer') }; + +# Configuration dir/file +my $home_dir; +my $xdg_config_home = $ENV{XDG_CONFIG_HOME}; + +if ($xdg_config_home and -d -w $xdg_config_home) { + require File::Basename; + $home_dir = File::Basename::dirname($xdg_config_home); + + if (not -d -w $home_dir) { + $home_dir = $ENV{HOME} || curdir(); + } +} +else { + $home_dir = + $ENV{HOME} + || $ENV{LOGDIR} + || ($^O eq 'MSWin32' ? '\Local Settings\Application Data' : ((getpwuid($<))[7] || `echo -n ~`)); + + if (not -d -w $home_dir) { + $home_dir = curdir(); + } + + $xdg_config_home = catdir($home_dir, '.config'); +} + +# Configuration dir/file +my $config_dir = catdir($xdg_config_home, 'pipe-viewer'); +my $config_file = catfile($config_dir, "gtk-pipe-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'); + +# Create the configuration directory +foreach my $dir ($config_dir) { + if (not -d $dir) { + require File::Path; + File::Path::make_path($dir) + or warn "[!] Can't create the configuration directory `$dir': $!"; + } +} + +# Video queue for the enqueue feature +my @VIDEO_QUEUE; + +sub which_command { + my ($cmd) = @_; + + if (file_name_is_absolute($cmd)) { + return $cmd; + } + + state $paths = [path()]; + foreach my $path (@{$paths}) { + if (-e (my $cmd_path = catfile($path, $cmd))) { + return $cmd_path; + } + } + return; +} + +my %symbols = ( + up_arrow => '↑', + down_arrow => '↓', + diamond => '❖', + face => '☺', + black_face => '☻', + average => 'x̄', + ellipsis => '…', + play => '▶', + views => '◈', + heart => '❤', + right_arrow => '→', + crazy_arrow => '↬', + numero => '№', + ); + +# Main configuration +my %CONFIG = ( + + # Combobox values + active_resolution_combobox => 0, + active_more_options_expander => 0, + active_panel_account_combobox => 0, + active_channel_type_combobox => 0, + active_subscriptions_order_combobox => 0, + + video_players => { + vlc => { + cmd => q{vlc}, + srt => q{--sub-file=*SUB*}, + audio => q{--input-slave=*AUDIO*}, + fs => q{--fullscreen}, + arg => q{--quiet --play-and-exit --no-video-title-show --input-title-format=*TITLE*}, + }, + mpv => { + cmd => q{mpv}, + srt => q{--sub-file=*SUB*}, + audio => q{--audio-file=*AUDIO*}, + fs => q{--fullscreen}, + arg => q{--really-quiet --force-media-title=*TITLE* --no-ytdl}, + }, + smplayer => { + cmd => q{smplayer}, + srt => q{-sub *SUB*}, + fs => q{-fullscreen}, + arg => q{-close-at-end -media-title *TITLE* *URL*}, + }, + }, + video_player_selected => undef, # autodetect it later + + # GUI options + clear_text_entries_on_click => 0, + show_thumbs => 1, + clear_search_list => 1, + default_notebook_page => 1, + mainw_size => '700x400', + mainw_maximized => 0, + mainw_fullscreen => 0, + mainw_centered => 0, + hpaned_width => 250, + hpaned_position => 420, + + # Pipe 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 + + # API + api_host => "auto", + + # URI options + thumbnail_type => 'medium', + youtube_video_url => 'https://www.youtube.com/watch?v=%s', + youtube_playlist_url => 'https://www.youtube.com/playlist?list=%s', + youtube_channel_url => 'https://www.youtube.com/channel/%s', + + # Subtitle options + srt_languages => ['en', 'es'], + get_captions => 1, + auto_captions => 0, + cache_dir => undef, + + # Others + env_proxy => 1, + http_proxy => undef, + timeout => undef, + user_agent => undef, + cookie_file => undef, + prefer_fork => (($^O eq 'linux') ? 0 : 1), + debug => 0, + fullscreen => 0, + audio_only => 0, + + # youtube-dl support + ytdl => 1, + ytdl_cmd => undef, # auto-detect + + 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'}, + pipe_viewer => undef, + pipe_viewer_args => [], + 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, +); + +{ + my $config_documentation = <<"EOD"; +#!/usr/bin/perl + +# $appname $version - configuration file + +EOD + + # Save hash config to file + sub dump_configuration { + require Data::Dump; + open my $config_fh, '>', $config_file + or do { warn "[!] Can't open '${config_file}' for write: $!"; return }; + + my $dumped_config = q{our $CONFIG = } . Data::Dump::pp(\%CONFIG) . "\n"; + + if ($home_dir eq $ENV{HOME}) { + $dumped_config =~ s/\Q$home_dir\E/\$ENV{HOME}/g; + } + + print $config_fh $config_documentation, $dumped_config; + close $config_fh; + } +} + +# Creating config unless it exists +if (not -e $config_file or -z _) { + dump_configuration(); +} + +local $SIG{TERM} = \&on_mainw_destroy; +local $SIG{INT} = \&on_mainw_destroy; + +# Locating the .glade interface file and icons dir +my $glade_file = catfile($share_dir, "gtk-pipe-viewer.glade"); +my $icons_path = catdir($share_dir, 'icons'); + +# Defining GUI +my $gui = 'Gtk3::Builder'->new; +$gui->add_from_file($glade_file); +$gui->connect_signals(undef); + +# GValue wrapper (unused for now) +sub gval ($$) { + Glib::Object::Introspection::GValueWrapper->new('Glib::' . ucfirst($_[0]) => $_[1]); +} + +# Convert a string into an array-ref of bytes +sub gcarray ($) { + [map { ord } split(//, $_[0])] +} + +# ------------- Get GUI objects ------------- # + +my %objects = ( + + # Windows + '__MAIN__' => \my $mainw, + 'users_list_window' => \my $users_list_window, + 'help_window' => \my $help_window, + 'prefernces_window' => \my $prefernces_window, + 'errors_window' => \my $errors_window, + 'login_to_youtube' => \my $login_to_youtube, + 'details_window' => \my $details_window, + 'aboutdialog1' => \my $about_window, + 'feeds_window' => \my $feeds_window, + 'warnings_window' => \my $warnings_window, + + # Others + 'treeview1' => \my $users_treeview, + 'feeds_statusbar' => \my $feeds_statusbar, + 'treeview2' => \my $treeview, + 'treeview3' => \my $cat_treeview, + 'feeds_treeview' => \my $feeds_treeview, + 'liststore1' => \my $liststore, + 'liststore2' => \my $users_liststore, + 'liststore4' => \my $cats_liststore, + 'liststore11' => \my $feeds_liststore, + 'textview3' => \my $config_view, + 'warnings_textview' => \my $warnings_textview, + 'errors_textview' => \my $errors_textview, + 'search_entry' => \my $search_entry, + 'statusbar1' => \my $statusbar, + 'treeviewcolumn2' => \my $thumbs_column, + 'textview2' => \my $textview_help, + 'from_author_entry' => \my $from_author_entry, + 'more_options_expander' => \my $more_options_expander, + 'notebook1' => \my $notebook, + 'comboboxtext9' => \my $resolution_combobox, + 'comboboxtext8' => \my $duration_combobox, + 'comboboxtext3' => \my $caption_combobox, + 'comboboxtext4' => \my $definition_combobox, + 'comboboxtext1' => \my $published_within_combobox, + 'comboboxtext13' => \my $subscriptions_order_combobox, + 'panel_user_entry' => \my $panel_user_entry, + 'comboboxtext6' => \my $panel_account_type_combobox, + 'comboboxtext2' => \my $order_combobox, + 'comboboxtext7' => \my $channel_type_combobox, + 'comboboxtext10' => \my $search_for_combobox, + 'spinbutton1' => \my $spin_results, + 'spinbutton2' => \my $spin_start_with_page, + 'thumbs_checkbutton' => \my $thumbs_checkbutton, + 'fullscreen_checkbutton' => \my $fullscreen_checkbutton, + '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, + 'channel_id_save' => \my $save_channel_id_entry, + 'main-menu-history-menu' => \my $history_menu, +); + +while (my ($key, $value) = each %objects) { + my $object = $gui->get_object($key); + if (defined $object) { + ${$value} = $object; + } + else { + print STDERR "[WARN] undefined object: $key\n"; + } +} + +# __WARN__ handle +local $SIG{__WARN__} = sub { + my $warning = strip_spaces(join('', @_)); + + say STDERR $warning; + + return if $warning =~ / at \(eval /; + return if $warning =~ /\bunhandled exception in callback:/; + return if $warning =~ /, or \} expected while parsing object\/hash/; + + $warning = "[" . localtime(time) . "]: " . $warning . "\n"; + + set_text($warnings_textview, $warning, append => 1); +}; + +# __DIE__ handle +local $SIG{__DIE__} = sub { + my $caller = [caller]->[0]; + my $error = strip_spaces(join('', @_)); + + say STDERR $error; + + # Ignore harmless errors + return if $error =~ / at \(eval /; + return if $error =~ /, or \} expected while parsing object\/hash/; + + # Ignore third-party errors + if (not $caller =~ /^(?:main\z|WWW::PipeViewer\b)/) { + return; + } + + set_text( + $errors_textview, + $error . do { + if ($error =~ /^Can't locate (.+?)\.pm\b/) { + my $module = $1; + $module =~ s{[/\\]+}{::}g; + return if $module eq 'LWP::UserAgent::Cached'; + "\nThe module $module is required!\n\nTo install it, just type in terminal:\n\tsudo cpan $module\n"; + } + } + . "\n\n=>> Previous warnings:\n" . get_text($warnings_textview) + ); + + warn "$error\n"; + $errors_window->show; + return 1; +}; + +#---------------------- LOAD IMAGES ----------------------# +my $app_icon_pixbuf = 'Gtk3::Gdk::Pixbuf'->new_from_file(catfile($icons_path, "gtk-pipe-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 $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"); +$mainw->set_icon($app_icon_pixbuf); + +our $CONFIG; +require $config_file; # Load the configuration file + +if (ref $CONFIG ne 'HASH') { + die "ERROR: Invalid configuration file!\n\t\$CONFIG is not an HASH ref!"; +} + +# Get valid config keys +my @valid_keys = grep { exists $CONFIG{$_} } keys %{$CONFIG}; +@CONFIG{@valid_keys} = @{$CONFIG}{@valid_keys}; + +# Define the cache directory +if (not defined $CONFIG{cache_dir}) { + + my $cache_dir = + ($ENV{XDG_CACHE_HOME} and -d -w $ENV{XDG_CACHE_HOME}) + ? $ENV{XDG_CACHE_HOME} + : catdir($home_dir, '.cache'); + + if (not -d -w $cache_dir) { + $cache_dir = catdir(curdir(), '.cache'); + } + + $CONFIG{cache_dir} = catdir($cache_dir, 'pipe-viewer'); +} + +foreach my $path ($CONFIG{cache_dir}) { + next if -d $path; + require File::Path; + File::Path::make_path($path) + or warn "[!] Can't create path <<$path>>: $!"; +} + +{ + my $split_string = sub { + grep { $_ ne '' } split(/\W+/, CORE::fc($_[0])); + }; + + my %history_dict; + + sub update_history_dict { + my (@entries) = @_; + + foreach my $str (@entries) { + my $str_ref = \$str; + + # Create models from each word of the string + foreach my $word ($split_string->($str)) { + my $ref = \%history_dict; + foreach my $char (split(//, $word)) { + $ref = $ref->{$char} //= {}; + push @{$ref->{values}}, $str_ref; + } + } + } + } + + my $completion; + + sub analyze_text { + my ($buffer) = @_; + + $completion // return; + my $text = $buffer->get_text; + my @tokens = $split_string->($text); + + my (@words, @matches, %analyzed); + foreach my $word (@tokens) { + + my $ref = \%history_dict; + foreach my $char (split(//, $word)) { + if (exists $ref->{$char}) { + $ref = $ref->{$char}; + } + else { + $ref = undef; + last; + } + } + + if (defined $ref and exists $ref->{values}) { + push @words, $word; + foreach my $match (@{$ref->{values}}) { + if (not exists $analyzed{$match}) { + undef $analyzed{$match}; + unshift @matches, $$match; + } + } + } + else { + @matches = (); # don't include partial matches + last; + } + } + + foreach my $token (@tokens) { + @matches = grep { index(CORE::fc($_), $token) != -1 } @matches; + } + + my $store = Gtk3::ListStore->new(['Glib::String']); + + my $i = 0; + foreach my $str ( + map { $_->[0] } + sort { $b->[1] <=> $a->[1] } + map { + my @parts = $split_string->($_); + + my $end_w = $#words; + my $end_p = $#parts; + + my $min_end = $end_w < $end_p ? $end_w : $end_p; + + my $order_score = 0; + for (my $i = 0 ; $i <= $min_end ; ++$i) { + my $word = $words[$i]; + + for (my $j = $i ; $j <= $end_p ; ++$j) { + my $part = $parts[$j]; + + my $matched; + my $continue = 1; + while ($part eq $word) { + $order_score += 1 - 1 / (length($word) + 1)**2; + $matched ||= 1; + $part = $parts[++$j] // do { $continue = 0; last }; + $word = $words[++$i] // do { $continue = 0; last }; + } + + if ($matched) { + $order_score += 1 - 1 / (length($word) + 1) + if ($continue and index($part, $word) == 0); + last; + } + elsif (index($part, $word) == 0) { + $order_score += length($word) / length($part); + last; + } + } + } + + my $prefix_score = 0; + foreach my $i (0 .. $min_end) { + ( + ($parts[$i] eq $words[$i]) + ? do { + $prefix_score += 1; + 1; + } + : (index($parts[$i], $words[$i]) == 0) ? do { + $prefix_score += length($words[$i]) / length($parts[$i]); + 0; + } + : 0 + ) + || last; + } + + ## printf("score('@parts', '@words') = %.4g + %.4g = %.4g\n", + ## $order_score, $prefix_score, $order_score + $prefix_score); + + [$_, $order_score + $prefix_score] + } @matches + ) { + my $iter = $store->append; + $store->set($iter, [0], [$str]); + last if ++$i == $CONFIG{entry_completion_limit}; + } + + $completion->set_model($store); + } + + my %history; + my $history_fh; + + sub set_history { + defined($history_fh) && return 1; + + # Open the history file for appending + if (open($history_fh, '>>:utf8', $CONFIG{history_file})) { + select((select($history_fh), $| = 1)[0]); # autoflush + } + else { + warn "[!] Can't open history file `$CONFIG{history_file}' for appending: $!"; + return; + } + + # Slurp the history file into memory + my @history; + my @search_history; + + if (open(my $fh, '<:utf8', $CONFIG{history_file})) { + chomp(@history = <$fh>); + } + + foreach my $line (@history) { + if (substr($line, 0, 1) eq '~') { + $line = substr($line, 1); + } + else { + unshift @search_history, $line; + } + undef $history{CORE::fc($line)}; + } + + require List::Util; + + # Workaround for List::Util < 1.45 + if (!defined(&List::Util::uniq)) { + *List::Util::uniq = sub { + my %seen; + grep { !$seen{$_}++ } @_; + }; + } + + # Keep only the most recent non-duplicated entries + @history = reverse(List::Util::uniq(reverse(@history))); + @search_history = List::Util::uniq(@search_history); + + # Set entry completion + $completion = Gtk3::EntryCompletion->new; + $completion->set_match_func(sub { 1 }); + $completion->set_text_column(0); + $search_entry->set_completion($completion); + + # Create the completion dictionary + update_history_dict(@history); + + my $recent_top = $CONFIG{recent_history}; + + if ($recent_top > scalar(@search_history)) { + $recent_top = scalar(@search_history); + } + + my @recent_history = grep { defined($_) } @search_history[0 .. $recent_top - 1]; + + if (not @recent_history or $recent_top <= 0) { + $gui->get_object('main-menu-history')->set_visible(0); + } + + foreach my $text (@recent_history) { + + my $label = $text; + if (length($label) > 30) { + $label = substr($label, 0, 30) . '...'; + } + + my $item = 'Gtk3::ImageMenuItem'->new($label); + $item->signal_connect( + activate => sub { + $search_entry->set_text($text); + $search_entry->set_position(length($text)); + search(); + } + ); + $item->set_property(tooltip_text => $text); + $item->set_image('Gtk3::Image'->new_from_icon_name("history-view", q{menu})); + $item->show; + $history_menu->append($item); + } + + # Keep only the most recent half of the history file when the limit has been reached + if ($CONFIG{history_limit} > 0 and $#history >= $CONFIG{history_limit}) { + + # Try to create a backup, first + require File::Copy; + File::Copy::cp($CONFIG{history_file}, "$CONFIG{history_file}.bak"); + + # Now, try to rewrite the history file + if (open(my $fh, '>:utf8', $CONFIG{history_file})) { + + # Keep only the most recent half part of the history file + say {$fh} join("\n", @history[($CONFIG{history_limit} >> 1) .. $#history]); + close $fh; + } + } + + return 1; + } + + sub append_to_history { + my ($text, $is_search_keyword) = @_; + + my $str = join(' ', split(' ', $text)); + + if ($is_search_keyword or not exists $history{CORE::fc($str)}) { + if (set_history()) { + + if ($is_search_keyword) { + say {$history_fh} $str; + } + else { + say {$history_fh} "~" . $str; + } + } + undef $history{CORE::fc($str)}; + update_history_dict($str); + } + } +} + +# Locate youtube-dl +if (not defined $CONFIG{ytdl_cmd}) { + if (defined(my $path = which_command('youtube-dl'))) { + $CONFIG{ytdl_cmd} = $path; + } + else { + $CONFIG{ytdl_cmd} = 'youtube-dl'; + } +} + +# Locate video player +if (not $CONFIG{video_player_selected}) { + + foreach my $key (sort keys %{$CONFIG{video_players}}) { + if (defined(my $abs_player_path = which_command($CONFIG{video_players}{$key}{cmd}))) { + $CONFIG{video_players}{$key}{cmd} = $abs_player_path; + $CONFIG{video_player_selected} = $key; + last; + } + } + + if (not $CONFIG{video_player_selected}) { + warn "\n[!] Please install a supported video player! (e.g.: mpv)\n\n"; + $CONFIG{video_player_selected} = 'mpv'; + } +} + +{ + my $update_config = 0; + + foreach my $key (keys %CONFIG) { + if (not exists $CONFIG->{$key}) { + $update_config = 1; + last; + } + } + + dump_configuration() if $update_config; +} + +# Locate a terminal +if (not defined $CONFIG{terminal}) { + foreach my $term ( + 'gnome-terminal', 'lxterminal', 'terminal', 'xfce4-terminal', + 'sakura', 'st', 'lilyterm', 'evilvte', + 'superterm', 'terminator', 'kterm', 'mlterm', + 'mrxvt', 'rxvt', 'urxvt', 'termite', + 'termit', 'fbterm', 'stjerm', 'yakuake', + 'tilix', 'roxterm', 'xterm', + ) { + if (defined(my $abs_path = which_command($term))) { + $CONFIG{terminal} = $abs_path; + + # Some terminals require changing the default value of `terminal_exec`. + # Probably more terminals require this modification. PRs are welcome. + if ( $term eq 'st' + or $term eq 'lxterminal') { + $CONFIG{terminal_exec} = '-e %s'; + } + + last; + } + } + + $CONFIG{terminal} //= $ENV{TERM} || 'xterm'; +} + +my %ResultsHistory = ( + current => -1, + results => [], + ); + +# Locate CLI pipe-viewer +$CONFIG{pipe_viewer} //= which_command('pipe-viewer') // 'pipe-viewer'; + +my $yv_obj = WWW::PipeViewer->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}, + ); + +#$yv_obj->load_authentication_tokens(); + +if (defined $yv_obj->get_access_token()) { + show_user_panel(); +} +else { + $statusbar->push(1, 'Not logged in.'); +} + +require WWW::PipeViewer::Utils; +my $yv_utils = WWW::PipeViewer::Utils->new(thousand_separator => $CONFIG{thousand_separator}, + youtube_url_format => $CONFIG{youtube_video_url},); + +# Set default combobox values +$definition_combobox->set_active(0); +$duration_combobox->set_active(0); +$caption_combobox->set_active(0); +$order_combobox->set_active(0); + +# Spin button start with page +$spin_start_with_page->set_value(1); + +sub apply_configuration { + + # Fullscreen mode + $fullscreen_checkbutton->set_active($CONFIG{fullscreen}); + + # Audio-only mode + $audio_only_checkbutton->set_active($CONFIG{audio_only}); + + # DASH mode + $dash_checkbutton->set_active($CONFIG{dash_support}); + + $clear_list_checkbutton->set_active($CONFIG{clear_search_list}); + $panel_account_type_combobox->set_active($CONFIG{active_panel_account_combobox}); + $channel_type_combobox->set_active($CONFIG{active_channel_type_combobox}); + $subscriptions_order_combobox->set_active($CONFIG{active_subscriptions_order_combobox}); + + $published_within_combobox->set_active(0); + + 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 + ) + ) { + + if (defined $CONFIG{$option_name}) { + my $code = \&{"WWW::PipeViewer::set_$option_name"}; + my $value = $CONFIG{$option_name}; + my $set_value = $yv_obj->$code($value); + + if (not defined($set_value) or $set_value ne $value) { + warn "[!] Invalid value <$value> for option <$option_name>.\n"; + } + } + } + + # Maximum number of results per page + $spin_results->set_value($CONFIG{maxResults}); + + # Enable/disable thumbnails + $thumbs_checkbutton->set_active($CONFIG{show_thumbs}); + + # Set the "More options" expander + $more_options_expander->set_expanded($CONFIG{active_more_options_expander}); + + my %resolution = ( + 'best' => 0, + '2160' => 1, + '1440' => 2, + '1080' => 3, + '720' => 4, + '480' => 5, + '360' => 6, + '240' => 7, + ); + + my $name = ($CONFIG{resolution} =~ /^(\d+)/) ? $1 : $CONFIG{resolution}; + + if (exists $resolution{$name}) { + $resolution_combobox->set_active($resolution{$name}); + } + else { + $resolution_combobox->set_active($CONFIG{active_resolution_combobox}); + } + + # Resize the main window + $mainw->set_default_size(split(/x/i, $CONFIG{mainw_size}, 2)); + + # Center the main window + if ($CONFIG{mainw_centered}) { + $mainw->set_position("center"); + } + + $mainw->reshow_with_initial_size; + + if ($CONFIG{mainw_maximized}) { + $mainw->maximize(); + } + + if ($CONFIG{mainw_fullscreen}) { + maximize_unmaximize_mainw(); + } + + # Support for history input + if ($CONFIG{history}) { + set_history(); + } + + # HPaned position correction + if ($CONFIG{hpaned_position} >= ($mainw->get_size)[0] - 200) { + $CONFIG{hpaned_position} = ($mainw->get_size)[0] - $CONFIG{hpaned_width}; + } + + # Set HPaned position + $hbox2->set_position($CONFIG{hpaned_position}); + + # Select text from text entry + $search_entry->select_region(0, -1); +} + +# Apply the configuration file +apply_configuration(); + +# YouTube usernames +set_usernames(); + +sub donate { + open_external_url('https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=75FUVBE6Q73T8'); +} + +# Set text to a 'textview' object +sub set_text { + my ($object, $text, %args) = @_; + my $object_buffer = $object->get_buffer; + + if ($args{append}) { + my $iter = $object_buffer->get_end_iter; + $object_buffer->insert($iter, $text); + } + else { + $object_buffer->set_text($text); + } + $object->set_buffer($object_buffer); + return 1; +} + +# Get text from a 'textview' object +sub get_text { + my ($object) = @_; + my $object_buffer = $object->get_buffer; + my $start_iter = $object_buffer->get_start_iter; + my $end_iter = $object_buffer->get_end_iter; + $object_buffer->get_text($start_iter, $end_iter, undef); +} + +sub new_image_from_pixbuf { + my ($object_name, $pixbuf) = @_; + my $object = $gui->get_object($object_name) // return; + scalar($object->new_from_pixbuf($pixbuf)); +} + +# Setting application icons +{ + $gui->get_object('username_list')->set_image(new_image_from_pixbuf('icon_from_pixbuf', $user_icon_pixbuf)); + $gui->get_object('uploads_button')->set_image(new_image_from_pixbuf('icon_from_pixbuf', $user_icon_pixbuf)); + $gui->get_object('button6')->set_image(new_image_from_pixbuf('icon_from_pixbuf', $feed_icon_pixbuf)); +} + +# Treeview signals +{ + $treeview->signal_connect('button_press_event', \&menu_popup); + $users_treeview->signal_connect('button_press_event', \&users_menu_popup); +} + +# Menu popup +sub menu_popup { + my ($treeview, $event) = @_; + + # Ignore non-right-clicks + if ($event->button != 3) { + return 0; + } + + ##my ($path, $col, $cell_x, $cell_y) = ...; + 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 $type = $liststore->get($iter, 7); + + # Ignore the right-click on 'next-page' entry + $type eq 'next_page' and return 0; + + # Create the main right-click menu + my $menu = 'Gtk3::Menu'->new; + + # 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); + } + + # Comments + { + my $item = 'Gtk3::ImageMenuItem'->new("YouTube comments"); + $item->set_image('Gtk3::Image'->new_from_icon_name("edit-copy", q{menu})); + $item->signal_connect(activate => \&show_comments_window); + $item->show; + $menu->append($item); + } + + # Separator + { + my $item = 'Gtk3::SeparatorMenuItem'->new; + $item->show; + $menu->append($item); + } + + # Video submenu + { + my $video = 'Gtk3::Menu'->new; + my $cat = 'Gtk3::ImageMenuItem'->new("Video"); + $cat->set_image('Gtk3::Image'->new_from_icon_name("video-x-generic", q{menu})); + $cat->show; + + # Play + { + my $item = 'Gtk3::ImageMenuItem'->new("Play"); + $item->signal_connect(activate => \&get_code); + $item->set_property(tooltip_text => "Play the video"); + $item->set_image('Gtk3::Image'->new_from_icon_name("media-playback-start-symbolic", q{menu})); + $item->show; + $video->append($item); + } + + # Enqueue + { + my $item = 'Gtk3::ImageMenuItem'->new("Enqueue"); + $item->signal_connect(activate => sub { enqueue_video() }); + $item->set_property(tooltip_text => "Enqueue video to play it later"); + $item->set_image('Gtk3::Image'->new_from_icon_name("list-add-symbolic", q{menu})); + $item->show; + $video->append($item); + } + + # Favorite + { + my $item = 'Gtk3::ImageMenuItem'->new("Favorite"); + $item->set_property(tooltip_text => "Save the video to favorites"); + $item->signal_connect( + activate => sub { + $yv_obj->favorite_video($video_id) + or warn "Failed to favorite the video <$video_id>: $!"; + } + ); + $item->set_image('Gtk3::Image'->new_from_icon_name("starred-symbolic", q{menu})); + $item->show; + $video->append($item); + } + + # Download + { + my $item = 'Gtk3::ImageMenuItem'->new("Download"); + $item->set_property(tooltip_text => "Download the video"); + $item->signal_connect(activate => \&download_video); + $item->set_image('Gtk3::Image'->new_from_icon_name("document-save-symbolic", q{menu})); + $item->show; + $video->append($item); + } + + # Separator + { + my $item = 'Gtk3::SeparatorMenuItem'->new; + $item->show; + $video->append($item); + } + + # Like + { + my $item = 'Gtk3::ImageMenuItem'->new("Like"); + $item->set_property(tooltip_text => "Send a positive rating"); + $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>: $!"; + } + ); + $item->set_image('Gtk3::Image'->new_from_icon_name("go-up-symbolic", q{menu})); + $item->show; + $video->append($item); + } + + # Disike + { + my $item = 'Gtk3::ImageMenuItem'->new("Dislike"); + $item->set_property(tooltip_text => "Send a negative rating"); + $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>: $!"; + } + ); + $item->set_image('Gtk3::Image'->new_from_icon_name("go-down-symbolic", q{menu})); + $item->show; + $video->append($item); + } + + # Separator + { + my $item = 'Gtk3::SeparatorMenuItem'->new; + $item->show; + $video->append($item); + } + + # Related videos + { + my $item = 'Gtk3::ImageMenuItem'->new("Related videos"); + $item->set_property(tooltip_text => "Display videos that are related to this video"); + $item->signal_connect(activate => \&show_related_videos); + $item->set_image('Gtk3::Image'->new_from_icon_name("video-x-generic-symbolic", q{menu})); + $item->show; + $video->append($item); + } + + # Open the YouTube video page + { + my $item = 'Gtk3::ImageMenuItem'->new("YouTube page"); + $item->signal_connect(activate => sub { open_external_url(make_youtube_url('video', $video_id)) }); + $item->set_property(tooltip_text => "Open the YouTube page of this video"); + $item->set_image('Gtk3::Image'->new_from_icon_name("applications-internet-symbolic", q{menu})); + $item->show; + $video->append($item); + } + + $cat->set_submenu($video); + $menu->append($cat); + } + } + elsif ($type eq 'playlist') { + + my $playlist_id = $liststore->get($iter, 3); + + # More details + { + my $item = 'Gtk3::ImageMenuItem'->new("Videos"); + $item->set_property(tooltip_text => "Display the videos from this playlist"); + $item->signal_connect(activate => sub { list_playlist($playlist_id) }); + $item->set_image('Gtk3::Image'->new_from_icon_name("folder-open", q{menu})); + $item->show; + $menu->append($item); + } + + # Separator + { + my $item = 'Gtk3::SeparatorMenuItem'->new; + $item->show; + $menu->append($item); + } + } + + my $channel_id = $liststore->get($iter, 6); + + # Author submenu + { + my $author = 'Gtk3::Menu'->new; + my $cat = 'Gtk3::ImageMenuItem'->new("Author"); + $cat->set_image('Gtk3::Image'->new_from_pixbuf($user_icon_pixbuf)); + $cat->show; + + # Recent uploads from this author + { + my $item = 'Gtk3::ImageMenuItem'->new("Uploads"); + $item->signal_connect(activate => sub { uploads('channel', $channel_id) }); + $item->set_property(tooltip_text => "Show the most recent videos from this author"); + $item->set_image('Gtk3::Image'->new_from_icon_name("emblem-shared-symbolic", q{menu})); + $item->show; + $author->append($item); + } + + # Most popular uploads from this author + { + my $item = 'Gtk3::ImageMenuItem'->new("Popular"); + $item->signal_connect(activate => sub { popular_uploads('channel', $channel_id) }); + $item->set_property(tooltip_text => "Show the most popular videos from this author"); + $item->set_image('Gtk3::Image'->new_from_icon_name("emblem-videos-symbolic", q{menu})); + $item->show; + $author->append($item); + } + + # Favorites of this author + { + my $item = 'Gtk3::ImageMenuItem'->new("Favorites"); + $item->signal_connect(activate => sub { favorites('channel', $channel_id) }); + $item->set_property(tooltip_text => "Show favorite videos of this author"); + $item->set_image('Gtk3::Image'->new_from_icon_name("emblem-favorite-symbolic", q{menu})); + $item->show; + $author->append($item); + } + + # Recent channel activity events + { + my $item = 'Gtk3::ImageMenuItem'->new("Activities"); + $item->signal_connect(activate => sub { activities('channel', $channel_id) }); + $item->set_property(tooltip_text => "Show recent channel activity events"); + $item->set_image('Gtk3::Image'->new_from_icon_name("view-refresh-symbolic", q{menu})); + $item->show; + $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); + } + + # Liked videos by this author + { + my $item = 'Gtk3::ImageMenuItem'->new("Likes"); + $item->signal_connect(activate => sub { likes('channel', $channel_id) }); + $item->set_property(tooltip_text => "Show liked videos by this author"); + $item->set_image('Gtk3::Image'->new_from_icon_name("emblem-default-symbolic", q{menu})); + $item->show; + $author->append($item); + } + + # Separator + { + my $item = 'Gtk3::SeparatorMenuItem'->new; + $item->show; + $author->append($item); + } + + # 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->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); + } + + # Open the YouTube channel page + { + my $item = 'Gtk3::ImageMenuItem'->new("YouTube page"); + $item->signal_connect(activate => sub { open_external_url(make_youtube_url('channel', $channel_id)) }); + $item->set_property(tooltip_text => "Open the YouTube page of this channel"); + $item->set_image('Gtk3::Image'->new_from_icon_name("applications-internet-symbolic", q{menu})); + $item->show; + $author->append($item); + } + + if ($type eq 'video' or $type eq 'playlist') { + $cat->set_submenu($author); + $menu->append($cat); + } + else { + $menu = $author; + } + } + + if (@VIDEO_QUEUE) { + + # Separator + { + my $item = 'Gtk3::SeparatorMenuItem'->new; + $item->show; + $menu->append($item); + } + + # Play enqueued videos + { + my $item = 'Gtk3::ImageMenuItem'->new("Play enqueued videos"); + $item->signal_connect(activate => \&play_enqueued_videos); + $item->set_property(tooltip_text => "Play the enqueued videos (if any)"); + $item->set_image('Gtk3::Image'->new_from_icon_name("media-playback-start", q{menu})); + $item->show; + $menu->append($item); + } + } + + if ($type eq 'video' or $type eq 'playlist') { + + # Separator + { + my $item = 'Gtk3::SeparatorMenuItem'->new; + $item->show; + $menu->append($item); + } + + # Play as audio + { + my $item = 'Gtk3::ImageMenuItem'->new("Play as audio"); + $item->signal_connect( + activate => sub { + my ($video_id, $iter) = get_selected_entry_code(); + if (defined($video_id) and $liststore->get($iter, 7) eq 'video') { + execute_cli_pipe_viewer("--id=$video_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->show; + $menu->append($item); + } + + # Play with CLI pipe-viewer + { + my $item = 'Gtk3::ImageMenuItem'->new("Play in terminal"); + $item->signal_connect(activate => \&play_selected_video_with_cli_pipe_viewer); + $item->set_property(tooltip_text => "Play with pipe-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); + return 0; +} + +sub users_menu_popup { + my ($treeview, $event) = @_; + if ($event->button != 3) { + return 0; + } + my $menu = $gui->get_object('user_option_menu'); + $menu->popup(undef, undef, undef, undef, $event->button, $event->time); + return 0; +} + +# Setting help text +set_text( + $textview_help, <<"HELP_TEXT" + +# Key binds + + CTRL+H : help window + CTRL+L : login window + CTRL+P : preferences window + CTRL+Y : start CLI Pipe Viewer + CTRL+E : enqueue the selected video + + CTRL+U : show the saved user-list + CTRL+D : show more video details for a selected video + CTRL+W : show the warnings window + CTRL+G : show videos favorited by 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 + CTRL+S : add the author of a selected video to the user-list + CTRL+Q : close the application + + DEL : remove the selected entry from the list + F11 : minimize-maximize the main window + +HELP_TEXT + ); + +{ + my $font = Pango::FontDescription::from_string('Monospace 8'); + $textview_help->modify_font($font); +} + +# ------------------- Accels ------------------- # + +# Main window +my $accel = Gtk3::AccelGroup->new; +$accel->connect(ord('h'), ['control-mask'], ['visible'], \&show_help_window); +$accel->connect(ord('e'), ['control-mask'], ['visible'], \&enqueue_video); +$accel->connect(ord('l'), ['control-mask'], ['visible'], \&show_login_to_youtube_window); +$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_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('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); +$accel->connect(0xffff, ['lock-mask'], ['visible'], \&delete_selected_row); +$accel->connect(0xffc8, ['lock-mask'], ['visible'], \&maximize_unmaximize_mainw); +$mainw->add_accel_group($accel); + +# Support for navigating back and forth using the side buttons of the mouse +$mainw->signal_connect( + 'button-release-event' => sub { + my (undef, $event) = @_; + + my $button = $event->button; + + if ($button == 8) { + display_previous_results(); + } + elsif ($button == 9) { + display_next_results(); + } + } +); + +# Other windows (ESC key to close them) +$accel = Gtk3::AccelGroup->new; +$accel->connect(0xff1b, ['lock-mask'], ['visible'], \&hide_users_list_window); +$users_list_window->add_accel_group($accel); + +$accel = Gtk3::AccelGroup->new; +$accel->connect(0xff1b, ['lock-mask'], ['visible'], \&hide_feeds_window); +$feeds_window->add_accel_group($accel); + +$accel = Gtk3::AccelGroup->new; +$accel->connect(0xff1b, ['lock-mask'], ['visible'], \&hide_preferences_window); +$accel->connect(ord('s'), ['control-mask'], ['visible'], \&save_configuration); +$prefernces_window->add_accel_group($accel); + +$accel = Gtk3::AccelGroup->new; +$accel->connect(0xff1b, ['lock-mask'], ['visible'], \&hide_help_window); +$help_window->add_accel_group($accel); + +$accel = Gtk3::AccelGroup->new; +$accel->connect(0xff1b, ['lock-mask'], ['visible'], \&hide_details_window); +$details_window->add_accel_group($accel); + +# ------------------ Authentication ------------------ # + +sub show_user_panel { + change_subscription_page(1); + $statusbar->push(1, "Logged in."); + return 1; +} + +# ------------------ Showing/Hidding windows ------------------ # + +# Main window +sub maximize_unmaximize_mainw { + state $maximized = 0; + $maximized++ % 2 + ? $mainw->unfullscreen + : $mainw->fullscreen; +} + +# Users list window +sub show_users_list_window { + $users_list_window->show; + return 1; +} + +sub hide_users_list_window { + $users_list_window->hide; + return 1; +} + +# Help window +sub show_help_window { + $help_window->show; + return 1; +} + +sub hide_help_window { + $help_window->hide; + return 1; +} + +# Warnings window + +sub show_warnings_window { + $warnings_window->show; + return 1; +} + +sub hide_warnings_window { + $warnings_window->hide; + return 1; +} + +# About Window +sub show_about_window { + $about_window->set_program_name("$appname $version"); + $about_window->set_logo($app_icon_pixbuf); + $about_window->set_resizable(1); + $about_window->show; + return 1; +} + +sub hide_about_window { + $about_window->hide; + return 1; +} + +# Error window +sub hide_errors_window { + $errors_window->hide; + return 1; +} + +# Login window +sub show_login_to_youtube_window { + $login_to_youtube->show; + return 1; +} + +sub hide_login_to_youtube_window { + $login_to_youtube->hide; + return 1; +} + +# Details window +sub show_details_window { + my ($code, $iter) = get_selected_entry_code(); + $code // return; + $details_window->show; + set_entry_details($code, $iter); + return 1; +} + +sub hide_details_window { + $details_window->hide; + return 1; +} + +sub set_comments { + my $videoID = get_selected_entry_code(type => 'video') // return; + $feeds_liststore->clear; + display_comments($yv_obj->comments_from_video_id($videoID)); +} + +# Comments window +sub show_comments_window { + my ($videoID, $iter) = get_selected_entry_code(type => 'video'); + $videoID // return; + + my $info = $liststore->get($iter, 0); + my ($video_title) = $info =~ m{^.*?(.*?)}s; + + $feeds_title->set_markup("$video_title"); + $feeds_title->set_tooltip_markup("$video_title"); + + $feeds_window->show; + $feeds_statusbar->pop(0); + + Glib::Idle->add( + sub { + display_comments($yv_obj->comments_from_video_id($videoID)); + return 0; + }, + [], + Glib::G_PRIORITY_DEFAULT_IDLE + ); + + return 1; +} + +sub hide_feeds_window { + $feeds_liststore->clear; + $feeds_window->hide; + return 1; +} + +# Preferences window +sub show_preferences_window { + require Data::Dump; + get_main_window_size(); + my $config_view_buffer = $config_view->get_buffer; + $config_view_buffer->set_text(Data::Dump::dump({map { ($_, $CONFIG{$_}) } grep { not /^active_/ } keys %CONFIG})); + $config_view->set_buffer($config_view_buffer); + state $font = Pango::FontDescription::from_string('Monospace 8'); + $config_view->modify_font($font); + $prefernces_window->show; + return 1; +} + +sub hide_preferences_window { + $prefernces_window->hide; + return 1; +} + +# Save plaintext config to file +sub save_configuration { + my $config = get_text($config_view); + + my $hash_ref = eval $config; + + print STDERR $@ if $@; + die $@ if $@; + + %CONFIG = (%CONFIG, %{$hash_ref}); + dump_configuration(); + + apply_configuration(); + hide_preferences_window(); + return 1; +} + +sub delete_selected_row { + my (undef, $iter) = get_selected_entry_code(); + $iter // return; + $liststore->remove($iter); + return 1; +} + +# Combo boxes changes +sub combobox_order_changed { + $yv_obj->set_order($order_combobox->get_active_text); +} + +sub combobox_resolution_changed { + $CONFIG{active_resolution_combobox} = $resolution_combobox->get_active; + my $res = $resolution_combobox->get_active_text; + $CONFIG{resolution} = $res =~ /^(\d+)p\z/ ? $1 : $res; +} + +sub combobox_duration_changed { + my $text = $duration_combobox->get_active_text; + $yv_obj->set_videoDuration($text); +} + +sub combobox_caption_changed { + my $text = $caption_combobox->get_active_text; + $yv_obj->set_videoCaption($text); +} + +sub combobox_subscriptions_order_changed { + $CONFIG{active_subscriptions_order_combobox} = $subscriptions_order_combobox->get_active; + $yv_obj->set_subscriptions_order($subscriptions_order_combobox->get_active_text); +} + +sub combobox_panel_account_changed { + my $text = $panel_account_type_combobox->get_active_text; + $CONFIG{active_panel_account_combobox} = $panel_account_type_combobox->get_active; + if ($text =~ /^(mine|myself)/i) { + $panel_user_entry->hide; + } + else { + $panel_user_entry->show; + } +} + +sub combobox_channel_type_changed { + $CONFIG{active_channel_type_combobox} = $channel_type_combobox->get_active; +} + +sub combobox_definition_changed { + my $text = $definition_combobox->get_active_text; + $yv_obj->set_videoDefinition($text); +} + +sub combobox_published_within_changed { + + my $period = $published_within_combobox->get_active_text; + + if ($period =~ /^any/) { + $yv_obj->set_date(undef); + } + else { + $yv_obj->set_date($period); + } +} + +# Spin buttons changes +sub spin_results_per_page_changed { + $yv_obj->set_maxResults($CONFIG{maxResults} = $spin_results->get_value); +} + +# Page number +sub spin_start_with_page_changed { + $yv_obj->set_page($spin_start_with_page->get_value); +} + +# Clear search list +sub toggled_clear_search_list { + $CONFIG{clear_search_list} = $clear_list_checkbutton->get_active() || 0; +} + +# Fullscreen mode +sub toggled_fullscreen { + $CONFIG{fullscreen} = $fullscreen_checkbutton->get_active() || 0; +} + +# Audio-only mode +sub toggled_audio_only { + $CONFIG{audio_only} = $audio_only_checkbutton->get_active() || 0; +} + +# DASH mode +sub toggled_dash_support { + $CONFIG{dash_support} = $dash_checkbutton->get_active() || 0; +} + +# Check buttons toggles +sub thumbs_checkbutton_toggled { + $CONFIG{show_thumbs} = ($_[0]->get_active() || 0); + $thumbs_column->set_visible($CONFIG{show_thumbs}); +} + +# "More options" expander +sub activate_more_options_expander { + $CONFIG{active_more_options_expander} = $_[0]->get_expanded() ? 0 : 1; +} + +# Get main window size +sub get_main_window_size { + $CONFIG{mainw_size} = join('x', $mainw->get_size); +} + +sub main_window_state_events { + my (undef, $state) = @_; + + my $windowstate = $state->new_window_state(); + my @states = split(' ', $windowstate); + + $CONFIG{mainw_maximized} = (grep { $_ eq 'maximized' } @states) ? 1 : 0; + $CONFIG{mainw_fullscreen} = (grep { $_ eq 'fullscreen' } @states) ? 1 : 0; + + return 1; +} + +sub add_category_header { + my ($text) = @_; + my $iter = $cats_liststore->append; + $cats_liststore->set($iter, [0], ["\t$text"]); + return 1; +} + +sub append_categories { + my ($categories, $type) = @_; + + foreach my $category (@$categories) { + + my $label = $category->{title}; + my $id = $category->{id}; + + $label =~ s{&}{&}g; + + my $iter = $cats_liststore->append; + + $cats_liststore->set( + $iter, + 0 => $label, + 1 => $id, + 2 => $feed_icon_pixbuf, + 3 => $type, + ); + } + return 1; +} + +append_categories($yv_obj->video_categories, 'cat'); + +my $tops_liststore = $gui->get_object('liststore6'); +my $tops_treeview = $gui->get_object('treeview4'); + +sub add_top_row { + my ($top_name, $top_type) = @_; + + (my $top_label = ucfirst $top_name) =~ tr/_/ /; + my $iter = $tops_liststore->append; + + $tops_liststore->set( + $iter, + 0 => $top_label, + 1 => $feed_icon_pixbuf, + 2 => $top_name, + 3 => $top_type, + ); +} + +sub set_youtube_tops { + my ($top_time, $main_label) = @_; + + ...; # Unimplemented! + + #my $iter = $tops_liststore->append; + #$tops_liststore->set($iter, 0, "\t$main_label"); + #add_top_row($name, $type); +} + +{ + my %channels; + + # ------------ 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>)) { + + $entry = unpack('A*', $entry); + my ($channel, $label) = split(' ', $entry, 2); + + 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', + ); + } + + foreach my $channel (sort { ($channels{$a} // lc($a)) cmp($channels{$b} // lc($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', + ); + } + + $users_liststore->set($iter, [3], [$user_icon_pixbuf]); + } + } + + 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/) { + + $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/) { + + $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; + } + + save_channel_by_id($channel_id, $channel_name); + } + + sub save_channel_by_id { + my ($channel_id, $channel_name) = @_; + + # Validate the channel ID + if (not defined($channel_id) or not $channel_id =~ /$valid_channel_id_re/) { + return; + } + + if ($channel_id =~ /$valid_channel_id_re/) { + $channel_id = $+{channel_id}; + } + + # 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; + } + + # Store it internally + $channels{$channel_id} = $channel_name; + + # 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, + ); + } + + sub add_user_to_favorites { + my $channel_id = get_channel_id_for_selected_video() // return; + save_channel_by_id($channel_id); + } + + 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); + } + + 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; + } + + # 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); + + playlists($type, $channel); + } + + sub videos_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); + + uploads($type, $channel); + } + + sub videos_from_saved_channel { + hide_users_list_window(); + videos_from_selected_username(); + } +} + +# ----- My panel settings ----- # +sub log_out { + change_subscription_page(0); + + unlink $authentication_file + or warn "Can't unlink: `$authentication_file' -> $!"; + + $yv_obj->set_access_token(); + $yv_obj->set_refresh_token(); + + $statusbar->push(1, "Not logged in."); + return 1; +} + +sub change_subscription_page { + my ($value) = @_; + foreach my $object (qw(subsc_scrollwindow subsc_label)) { + $value + ? $gui->get_object($object)->show + : $gui->get_object($object)->hide; + } + return 1; +} + +sub subscriptions_button { + my $type = $panel_account_type_combobox->get_active_text; + my $username = $panel_user_entry->get_text; + subscriptions($type, $username); +} + +sub favorites_button { + my $type = $panel_account_type_combobox->get_active_text; + my $username = $panel_user_entry->get_text; + favorites($type, $username); +} + +sub uploads_button { + my $type = $panel_account_type_combobox->get_active_text; + my $username = $panel_user_entry->get_text; + uploads($type, $username); +} + +sub likes_button { + my $type = $panel_account_type_combobox->get_active_text; + my $username = $panel_user_entry->get_text; + likes($type, $username); +} + +sub dislikes_button { + my $type = $panel_account_type_combobox->get_active_text; + my $username = $panel_user_entry->get_text; + dislikes($type, $username); +} + +sub playlists_button { + my $type = $panel_account_type_combobox->get_active_text; + my $username = $panel_user_entry->get_text; + playlists($type, $username); +} + +sub activity_button { + my $type = $panel_account_type_combobox->get_active_text; + my $username = $panel_user_entry->get_text; + activities($type, $username); +} + +sub popular_uploads { + my ($type, $channel) = @_; + + if ($type =~ /^user/) { + $channel = $yv_obj->channel_id_from_username($channel) // die "Invalid username <<$channel>>\n"; + } + + my $results = $yv_obj->popular_videos($channel); + + if ($yv_utils->has_entries($results)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($results); + } + else { + die "No popular uploads for channel: <<$channel>>\n"; + } +} + +{ + no strict 'refs'; + foreach my $name (qw(favorites uploads likes dislikes playlists subscriptions activities)) { + *{__PACKAGE__ . '::' . $name} = sub { + my ($type, $channel) = @_; + + my $method = $name; + + if ($yv_utils->is_channelID($channel)) { + $method = $name; + } + elsif ($type =~ /^user/i and $channel ne 'mine' and $channel =~ /^\S+\z/) { + $method = $name . '_from_username'; + } + elsif ($type =~ /^channel/i and $channel ne 'mine' and $channel =~ /^\S+\z/) { + $method = $name . '_from_username'; + } + + if ($type =~ /^(mine|myself)/i) { + if ($name eq 'likes') { + $method = 'my_likes'; + } + + if ($name eq 'playlists') { + $method = 'my_playlists'; + } + + if ($name eq 'activities') { + $method = 'my_activities'; + } + } + + if ($name eq 'dislikes') { + $method = 'my_dislikes'; + } + + my $request = $yv_obj->$method( + ($type =~ /^(user|channel)/i and $channel =~ /^\S+\z/) + ? $channel + : () + ); + + if ($yv_utils->has_entries($request)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($request); + } + else { + die "No $name results" . ($channel ? " for channel: <<$channel>>\n" : "\n"); + } + + return 1; + }; + } +} + +sub get_selected_entry_code { + my (%options) = @_; + my $iter = $treeview->get_selection->get_selected // return; + + if (exists $options{type}) { + my $type = $liststore->get($iter, 7) // return; + $type eq $options{type} or return; + } + + my $code = $liststore->get($iter, 3); + return wantarray ? ($code, $iter) : $code; +} + +sub check_keywords { + my ($key) = @_; + + if ($key =~ /$get_video_id_re/o) { + my $info = $yv_obj->video_details($+{video_id}); + if (ref($info) eq 'HASH' and keys %$info) { + play_video($info) || return; + } + else { + return; + } + } + elsif ($key =~ /$get_playlist_id_re/o) { + list_playlist($+{playlist_id}); + } + elsif ($key =~ /$get_channel_playlists_id_re/) { + list_channel_playlists($+{channel_id}); + } + elsif ($key =~ /$get_channel_videos_id_re/) { + list_channel_videos($+{channel_id}); + } + elsif ($key =~ /$get_username_playlists_re/) { + list_username_playlists($+{username}); + } + elsif ($key =~ /$get_username_videos_re/) { + list_username_videos($+{username}); + } + else { + return; + } + + return 1; +} + +sub search { + my $keywords = $search_entry->get_text(); + + return if check_keywords($keywords); + + $liststore->clear if $CONFIG{clear_search_list}; + + # Remember the input text when "history" is enabled + if ($CONFIG{history}) { + append_to_history($keywords, 1); + } + + # Set the username + my $username = $from_author_entry->get_text; + + if ($username =~ /^[\w\-]+\z/) { + my $id = $username; + + if (not $yv_utils->is_channelID($id)) { + $id = $yv_obj->channel_id_from_username($id) // undef; + } + + $yv_obj->set_channelId($id); + } + else { + $yv_obj->set_channelId(); + } + + my $type = $search_for_combobox->get_active_text; + display_results($yv_obj->search_for($type, $keywords)); + + return 1; +} + +sub encode_entities { + my ($text) = @_; + + return q{} if not defined $text; + + $text =~ s/&/&/g; + $text =~ s//>/g; + + return $text; +} + +sub decode_entities { + my ($text) = @_; + + return q{} if not defined $text; + + $text =~ s/&/&/g; + $text =~ s/<//g; + + return $text; +} + +sub get_code { + my ($code, $iter) = get_selected_entry_code(); + + $code // return; + + Glib::Idle->add( + sub { + my ($code, $iter) = @{$_[0]}; + + 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 { + + my $next_page_token = $liststore->get($iter, 5); + my $results = $yv_obj->next_page($code, $next_page_token); + + if ($yv_utils->has_entries($results)) { + my $label = '' . ('=' x 20) . ''; + $liststore->set($iter, 0 => $label, 3 => ""); + } + else { + $liststore->remove($iter); + die "This is the last page!\n"; + } + + display_results($results); + } + : $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 + ); +} + +sub make_row_description { + join(q{ }, split(q{ }, $_[0])) =~ s/(.)\1{3,}/$1/sgr; +} + +sub append_next_page { + my ($url, $continuation) = @_; + + $url // return; + my $iter = $liststore->append; + + $liststore->set( + $iter, + 0 => "LOAD MORE", + 3 => $url, + 5 => $continuation, + 7 => 'next_page', + ); +} + +sub determine_image_format { + # + ## Code from: https://metacpan.org/release/Image-Info/source/lib/Image/Info.pm + # + + local ($_) = @_; + return "JPEG" if /^\xFF\xD8/; + return "PNG" if /^\x89PNG\x0d\x0a\x1a\x0a/; + return "GIF" if /^GIF8[79]a/; + return "TIFF" if /^MM\x00\x2a/; + return "TIFF" if /^II\x2a\x00/; + return "BMP" if /^BM/; + return "ICO" if /^\000\000\001\000/; + return "PPM" if /^P[1-6]/; + return "XPM" if /(^\/\* XPM \*\/)|(static\s+char\s+\*\w+\[\]\s*=\s*{\s*"\d+)/; + return "XBM" if /^(?:\/\*.*\*\/\n)?#define\s/; + return "SVG" if /^(<\?xml|[\012\015\t ]*lwp_get($url, simple => 1); + $cache{$url} = $data if defined($data); + return $data; +} + +sub get_pixbuf_thumbnail_from_content { + my ($thumbnail, $xsize, $ysize) = @_; + + $xsize //= 160; + $ysize //= 90; + + require Digest::MD5; + + my $md5 = Digest::MD5::md5_hex($thumbnail); + my $key = "$md5 $xsize $ysize"; + + state %cache; + + if (exists $cache{$key}) { + return $cache{$key}; + } + + my $pixbuf; + if (defined $thumbnail) { + my $type = determine_image_format($thumbnail); + + my $pixbufloader; + if (defined($type)) { + $pixbufloader = eval { 'Gtk3::Gdk::PixbufLoader'->new_with_type(lc($type)) }; + } + if (not defined $pixbufloader) { + $pixbufloader = 'Gtk3::Gdk::PixbufLoader'->new; + } + + eval { + $pixbufloader->set_size($xsize, $ysize); + ## $pixbufloader->write($thumbnail); # Gtk3 bug? + $pixbufloader->write([unpack 'C*', $thumbnail]); + $pixbuf = $pixbufloader->get_pixbuf; + $pixbufloader->close; + }; + } + + if (defined($pixbuf)) { + $cache{$key} = $pixbuf; + } + + $pixbuf //= $default_thumb; + + return $pixbuf; +} + +sub get_pixbuf_thumbnail_from_url { + my ($url, $xsize, $ysize) = @_; + my $thumbnail = lwp_get($url); + return get_pixbuf_thumbnail_from_content($thumbnail, $xsize, $ysize); +} + +sub get_pixbuf_thumbnail_from_entry { + my ($entry) = @_; + + my $thumbnail_url = $yv_utils->get_thumbnail_url($entry, $CONFIG{thumbnail_type}); + my $thumbnail_data = ($entry->{_thumbnail_data} ||= lwp_get($thumbnail_url)); + + # Don't cache thumbnails that failed to be retrieved. + if (not $entry->{_thumbnail_data}) { + delete $entry->{_thumbnail_data}; + } + + my $square_format = $yv_utils->is_channel($entry) || $yv_utils->is_subscription($entry); + my $pixbuf = get_pixbuf_thumbnail_from_content($thumbnail_data, ($square_format ? (160, 160) : ())); + + return $pixbuf; +} + +sub display_results { + my ($results, $from_history) = @_; + + my $url = $results->{url}; + + #my $info = $results->{results} // {}; + my $items = $results->{results} // []; + + #use Data::Dump qw(pp); + #pp $items; + + if (ref($items) eq 'HASH') { + + if (exists $items->{videos}) { + $items = $items->{videos}; + } + elsif (exists $items->{playlists}) { + $items = $items->{playlists}; + } + else { + warn "No results...\n"; + } + } + + if (ref($items) ne 'ARRAY') { + + my $current_instance = $yv_obj->get_api_host(); + $yv_obj->pick_and_set_random_instance(); # set a random invidious instance + + die "Probably $current_instance is down.\n" + . "\nTry changing the `api_host` in configuration file:\n\n" + . qq{\tapi_host => "auto",\n} + . qq{\nSee also: https://github.com/trizen/pipe-viewer#invidious-instances\n}; + } + + if (not $yv_utils->has_entries($results)) { + die "No results...\n"; + } + + if (@$items) { + add_results_to_history($results) if not $from_history; + } + + #~ if (not $from_history) { + + #~ foreach my $entry (@$items) { + #~ if ($yv_utils->is_activity($entry)) { + #~ my $type = $entry->{snippet}{type}; + + #~ if ($type eq 'upload') { + #~ $entry->{kind} = 'youtube#video'; + #~ $entry->{id} = $entry->{contentDetails}{upload}{videoId}; + #~ } + + #~ if ($type eq 'playlistItem') { + #~ $entry->{kind} = 'youtube#video'; + #~ $entry->{id} = $entry->{contentDetails}{playlistItem}{resourceId}{videoId}; + #~ } + + #~ if ($type eq 'subscription') { + #~ $entry->{kind} = 'youtube#channel'; + #~ $entry->{snippet}{title} = $entry->{snippet}{channelTitle}; + #~ $entry->{snippet}{channelId} = $entry->{contentDetails}{subscription}{resourceId}{channelId}; + #~ } + + #~ if ($type eq 'bulletin' and $entry->{contentDetails}{bulletin}{resourceId}{kind} eq 'youtube#video') { + #~ $entry->{kind} = 'youtube#video'; + #~ $entry->{id} = $entry->{contentDetails}{bulletin}{resourceId}{videoId}; + #~ } + #~ } + #~ } + + #~ my @video_ids; + #~ my @playlist_ids; + + #~ foreach my $i (0 .. $#{$items}) { + #~ my $item = $items->[$i]; + + #~ if ($yv_utils->is_playlist($item)) { + #~ push @playlist_ids, $yv_utils->get_playlist_id($item); + #~ } + #~ elsif ($yv_utils->is_video($item)) { + #~ push @video_ids, $yv_utils->get_video_id($item); + #~ } + #~ } + + #~ my %id_lookup; + + #~ if (@video_ids) { + #~ my $content_details = $yv_obj->video_details(join(',', @video_ids), VIDEO_PART); + #~ my $video_details = $content_details->{results}{items}; + + #~ foreach my $i (0 .. $#video_ids) { + #~ $id_lookup{$video_ids[$i]} = $video_details->[$i]; + #~ } + #~ } + + #~ if (@playlist_ids) { + #~ my $content_details = $yv_obj->playlist_from_id(join(',', @playlist_ids), 'contentDetails'); + #~ my $playlist_details = $content_details->{results}{items}; + + #~ foreach my $i (0 .. $#playlist_ids) { + #~ $id_lookup{$playlist_ids[$i]} = $playlist_details->[$i]; + #~ } + #~ } + + #~ $info->{__extra_info__} = \%id_lookup; + #~ } + + foreach my $i (0 .. $#{$items}) { + my $item = $items->[$i]; + + if ($yv_utils->is_playlist($item)) { + + #~ my $playlist_id = $yv_utils->get_playlist_id($item) || next; + + #~ if (exists($info->{__extra_info__}{$playlist_id})) { + #~ @{$item}{qw(contentDetails)} = + #~ @{$info->{__extra_info__}{$playlist_id}}{qw(contentDetails)}; + #~ } + + add_playlist_entry($item); + } + elsif ($yv_utils->is_channel($item)) { + add_channel_entry($item); + } + elsif ($yv_utils->is_subscription($item)) { + add_subscription_entry($item); + } + elsif ($yv_utils->is_video($item)) { + + #~ my $video_id = $yv_utils->get_video_id($item) || next; + + #~ if (exists($info->{__extra_info__}{$video_id})) { + #~ @{$item}{qw(id contentDetails statistics snippet)} = + #~ @{$info->{__extra_info__}{$video_id}}{qw(id contentDetails statistics snippet)}; + #~ } + + # Filter out private or deleted videos + #$yv_utils->get_video_id($item) || next; + + # Filter out videos with time '00:00' + #$yv_utils->get_time($item) eq '00:00' and next; + + # Mark as video + #$item->{__is_video__} = 1; + + # Store the video title to history (when `save_titles_to_history` is true) + if ($CONFIG{save_titles_to_history}) { + append_to_history($yv_utils->get_title($item), 0); + } + + add_video_entry($item); + } + } + + if (ref($results->{results}) eq 'HASH' and exists($results->{results}{continuation})) { + if (defined $results->{results}{continuation}) { + append_next_page($url, $results->{results}{continuation}); + } + } + else { + append_next_page($url); + } +} + +sub set_entry_tooltip { + my ($iter, $title, $description) = @_; + + $CONFIG{tooltips} || return 1; + + if ($CONFIG{tooltip_max_len} > 0 and length($description) > $CONFIG{tooltip_max_len}) { + $description = substr($description, 0, $CONFIG{tooltip_max_len}) . '...'; + } + + $description =~ s/(?:\R\s*\R)+/\n\n/g; # replace 2+ consecutive newlines with "\n\n" + + $liststore->set($iter, [9], ["" . encode_entities($title) . "" . "\n\n" . encode_entities($description)]); +} + +sub set_thumbnail { + my ($entry, $liststore, $iter) = @_; + + $liststore->set($iter, [1], [$default_thumb]); + + Glib::Idle->add( + sub { + my ($entry, $liststore, $iter) = @{$_[0]}; + my $pixbuf = get_pixbuf_thumbnail_from_entry($entry); + $liststore->set($iter, [1], [$pixbuf]); + return 0; + }, + [$entry, $liststore, $iter], + Glib::G_PRIORITY_DEFAULT_IDLE + ); +} + +sub add_subscription_entry { + my ($subscription) = @_; + + my $iter = $liststore->append; + my $title = $yv_utils->get_title($subscription); + my $channel_id = $yv_utils->get_channel_id($subscription); + my $description = $yv_utils->get_description($subscription); + my $row_description = make_row_description($description); + + set_entry_tooltip($iter, $title, $description); + + my $title_label = + '' + . encode_entities($title) + . "\n\n" + . "$symbols{face}\t " + . encode_entities($channel_id) . "\n" + . "$symbols{crazy_arrow}\t " + . $yv_utils->get_publication_date($subscription) + . "\n\n" + . encode_entities($row_description) . ''; + + my $type_label = "$symbols{diamond} " . 'Subscription' . "\n"; + + $liststore->set( + $iter, + 0 => $title_label, + 2 => $type_label, + 3 => $channel_id, + 4 => encode_entities($description), + 6 => $channel_id, + 7 => 'subscription', + ); + + if ($CONFIG{show_thumbs}) { + set_thumbnail($subscription, $liststore, $iter); + } +} + +sub reflow_text { + my ($text) = @_; + $text =~ s/^/‎/gmr; +} + +sub add_video_entry { + my ($video) = @_; + + my $iter = $liststore->append; + my $title = $yv_utils->get_title($video); + my $video_id = $yv_utils->get_video_id($video); + my $channel_id = $yv_utils->get_channel_id($video); + my $description = $yv_utils->get_description($video); + my $row_description = make_row_description($description); + + 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)); + + $liststore->set( + $iter, + 0 => $title_label, + 2 => $info_label, + 3 => $video_id, + 4 => encode_entities($description), + 6 => $channel_id, + 7 => 'video', + 8 => $yv_obj->make_json_string($video), + ); + + if ($CONFIG{show_thumbs}) { + set_thumbnail($video, $liststore, $iter); + } +} + +sub add_channel_entry { + my ($channel) = @_; + + my $iter = $liststore->append; + my $title = $yv_utils->get_channel_title($channel); + my $channel_id = $yv_utils->get_channel_id($channel); + my $description = $yv_utils->get_description($channel); + my $row_description = make_row_description($description); + + 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"); + + $liststore->set( + $iter, + 0 => $title_label, + 2 => $type_label, + 3 => $channel_id, + 4 => encode_entities($description), + 6 => $channel_id, + 7 => 'channel', + ); + + if ($CONFIG{show_thumbs}) { + set_thumbnail($channel, $liststore, $iter); + } +} + +sub add_playlist_entry { + my ($playlist) = @_; + + my $iter = $liststore->append; + my $title = $yv_utils->get_title($playlist); + my $channel_id = $yv_utils->get_channel_id($playlist); + my $channel_title = $yv_utils->get_channel_title($playlist); + my $description = $yv_utils->get_description($playlist); + my $playlist_id = $yv_utils->get_playlist_id($playlist); + my $row_description = make_row_description($description); + + 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); + + $liststore->set( + $iter, + 0 => $title_label, + 2 => $type_label, + 3 => $playlist_id, + 4 => encode_entities($description), + 6 => $channel_id, + 7 => 'playlist', + ); + + if ($CONFIG{show_thumbs}) { + set_thumbnail($playlist, $liststore, $iter); + } +} + +sub list_playlist { + my ($playlist_id) = @_; + + my $results = $yv_obj->videos_from_playlist_id($playlist_id); + + if ($yv_utils->has_entries($results)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($results); + return 1; + } + else { + die "[!] Inexistent playlist...\n"; + } + return; +} + +sub list_channel_videos { + my ($channel_id) = @_; + + my $results = $yv_obj->uploads($channel_id); + + if ($yv_utils->has_entries($results)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($results); + return 1; + } + else { + die "[!] No videos for channel ID: $channel_id\n"; + } + return; +} + +sub list_username_videos { + my ($username) = @_; + + my $results = $yv_obj->uploads_from_username($username); + + if ($yv_utils->has_entries($results)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($results); + return 1; + } + else { + die "[!] No videos for user: $username\n"; + } + return; +} + +sub list_channel_playlists { + my ($channel_id) = @_; + + my $results = $yv_obj->playlists($channel_id); + + if ($yv_utils->has_entries($results)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($results); + return 1; + } + else { + die "[!] No playlists for channel ID: $channel_id\n"; + } + return; +} + +sub list_username_playlists { + my ($username) = @_; + + my $results = $yv_obj->playlists_from_username($username); + + if ($yv_utils->has_entries($results)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($results); + return 1; + } + else { + die "[!] No playlists for user: $username\n"; + } + return; +} + +sub favorites_from_text_entry { + my ($text_entry) = @_; + favorites($channel_type_combobox->get_active_text, $text_entry->get_text); +} + +sub uploads_from_text_entry { + my ($text_entry) = @_; + uploads($channel_type_combobox->get_active_text, $text_entry->get_text); +} + +sub playlists_from_text_entry { + my ($text_entry) = @_; + playlists($channel_type_combobox->get_active_text, $text_entry->get_text); +} + +sub likes_from_text_entry { + my ($text_entry) = @_; + likes($channel_type_combobox->get_active_text, $text_entry->get_text); +} + +sub subscriptions_from_text_entry { + my ($text_entry) = @_; + subscriptions($channel_type_combobox->get_active_text, $text_entry->get_text); +} + +sub strip_spaces { + my ($text) = @_; + $text =~ s/^\s+//; + return unpack 'A*', $text; +} + +sub get_streaming_url { + my ($video_id) = @_; + + my ($urls, $captions, $info) = $yv_obj->get_streaming_urls($video_id); + + if (not defined $urls) { + return scalar {}; + } + + # Download the closed-captions + my $srt_file; + if (ref($captions) eq 'ARRAY' and @$captions and $CONFIG{get_captions}) { + require WWW::PipeViewer::GetCaption; + my $yv_cap = WWW::PipeViewer::GetCaption->new( + 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); + } + + require WWW::PipeViewer::Itags; + state $yv_itags = WWW::PipeViewer::Itags->new(); + + my ($streaming, $resolution) = $yv_itags->find_streaming_url( + urls => $urls, + resolution => $CONFIG{resolution}, + + hfr => $CONFIG{hfr}, + ignore_av1 => $CONFIG{ignore_av1}, + + dash => $CONFIG{dash_support}, + dash_mp4_audio => $CONFIG{dash_mp4_audio}, + dash_segmented => $CONFIG{dash_segmented}, + ); + + return { + streaming => $streaming, + srt_file => $srt_file, + info => $info, + resolution => $resolution, + }; +} + +sub get_quotewords { + require Text::ParseWords; + return Text::ParseWords::quotewords(@_); +} + +#---------------------- PLAY AN YOUTUBE VIDEO ----------------------# +sub get_player_command { + my ($streaming, $video) = @_; + + my %MPLAYER; + + $MPLAYER{fullscreen} = $CONFIG{fullscreen} ? $CONFIG{video_players}{$CONFIG{video_player_selected}}{fs} : q{}; + $MPLAYER{arguments} = $CONFIG{video_players}{$CONFIG{video_player_selected}}{arg} // q{}; + + my $cmd = join( + q{ }, + ( + # Video player + $CONFIG{video_players}{$CONFIG{video_player_selected}}{cmd}, + + ( # Audio file (https://) + ref($streaming->{streaming}{__AUDIO__}) eq 'HASH' + && exists($CONFIG{video_players}{$CONFIG{video_player_selected}}{audio}) + ? $CONFIG{video_players}{$CONFIG{video_player_selected}}{audio} + : () + ), + + ( # Caption file (.srt) + defined($streaming->{srt_file}) + && exists($CONFIG{video_players}{$CONFIG{video_player_selected}}{srt}) + ? $CONFIG{video_players}{$CONFIG{video_player_selected}}{srt} + : () + ), + + # Rest of the arguments + grep({ defined($_) and /\S/ } values %MPLAYER) + ) + ); + + my $has_video = $cmd =~ /\*(?:VIDEO|URL|ID)\*/; + + $cmd = $yv_utils->format_text( + streaming => $streaming, + info => $video, + text => $cmd, + escape => 1, + ); + + if ($streaming->{streaming}{url} =~ m{^https://www\.youtube\.com/watch\?v=}) { + $cmd =~ s{\s*--no-ytdl\b}{ }g; + } + + $has_video ? $cmd : join(' ', $cmd, quotemeta($streaming->{streaming}{url})); +} + +sub play_video { + my ($video) = @_; + + my $video_id = $yv_utils->get_video_id($video); + my $streaming = get_streaming_url($video_id); + + if (ref($streaming->{streaming}) ne 'HASH') { + die "[!] Can't play this video: no streaming URL has been found!\n"; + } + + if ( not defined($streaming->{streaming}{url}) + and defined($streaming->{info}{status}) + and $streaming->{info}{status} =~ /(?:error|fail)/i) { + die "[!] Error on: " . sprintf($CONFIG{youtube_video_url}, $video_id) . "\n", + "[*] Reason: " . $streaming->{info}{reason} =~ tr/+/ /r . "\n"; + } + + my $command = get_player_command($streaming, $video); + + if ($yv_obj->get_debug) { + say "-> Resolution: $streaming->{resolution}"; + say "-> Video itag: $streaming->{streaming}{itag}"; + say "-> Audio itag: $streaming->{streaming}{__AUDIO__}{itag}" if exists $streaming->{streaming}{__AUDIO__}; + say "-> Video type: $streaming->{streaming}{type}"; + say "-> Audio type: $streaming->{streaming}{__AUDIO__}{type}" if exists $streaming->{streaming}{__AUDIO__}; + } + + my $code = execute_external_program($command); + warn "[!] Can't play this video -- player exited with code: $code\n" if $code != 0; + + return 1; +} + +sub list_category { + + my $iter = $cat_treeview->get_selection->get_selected; + my $cat_id = $cats_liststore->get($iter, 1); + my $type = $cats_liststore->get($iter, 3); + + my $videos = $yv_obj->trending_videos_from_category($cat_id); + + if ($yv_utils->has_entries($videos)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($videos); + } + else { + die "No video found for categoryID: <$cat_id>\n"; + } +} + +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); + + 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; + } + + $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) + ); +} + +sub clear_text { + my ($entry) = @_; + + if ($entry->get_text() =~ /\.\.\.\z/ or $CONFIG{clear_text_entries_on_click}) { + $entry->set_text(''); + } + + return 0; +} + +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-video' => $CONFIG{audio_only} ? q{} : undef, + ); + + while (my ($argv, $value) = each %options) { + push( + @args, + do { + $value ? '--' . $argv . '=' . $value + : defined($value) ? '--' . $argv + : next; + } + ); + } + return @args; +} + +sub execute_external_program { + my ($cmd) = @_; + + if ($CONFIG{prefer_fork} and defined(my $pid = fork())) { + if ($pid == 0) { + say "** Forking process: $cmd" if $yv_obj->get_debug; + $yv_obj->proxy_exec($cmd); + } + } + else { + say "** Backgrounding process: $cmd" if $yv_obj->get_debug; + $yv_obj->proxy_system($cmd . ' &'); + } +} + +sub make_youtube_url { + my ($type, $code) = @_; + + my $format = ( + ($type eq 'subscription' || $type eq 'channel') ? $CONFIG{youtube_channel_url} + : $type eq 'video' ? $CONFIG{youtube_video_url} + : $type eq 'playlist' ? $CONFIG{youtube_playlist_url} + : () + ); + + if (defined $format) { + return sprintf($format, $code); + } + + return "https://www.youtube.com"; +} + +sub open_external_url { + my ($url) = @_; + + my $exit_code = + execute_external_program(join(q{ }, $CONFIG{web_browser} // $ENV{WEBBROWSER} // 'xdg-open', quotemeta($url))); + + if ($exit_code != 0) { + warn "Can't open URL <<$url>> -- exit code: $exit_code\n"; + } + + return 1; +} + +sub enqueue_video { + my $video_id = get_selected_entry_code(type => 'video') // return; + print "[*] Added: <$video_id>\n" if $yv_obj->get_debug; + push @VIDEO_QUEUE, $video_id; + return 1; +} + +sub play_enqueued_videos { + if (@VIDEO_QUEUE) { + execute_cli_pipe_viewer('--video-ids=' . join(q{,}, splice @VIDEO_QUEUE)); + } + return 1; +} + +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_pipe_viewer("--video-id=$code"); + } + elsif ($type eq 'playlist') { + execute_cli_pipe_viewer("--pp=$code"); + } + else { + warn "Can't play $type: $code\n"; + } + + return 1; +} + +sub execute_cli_pipe_viewer { + my @arguments = @_; + + my $command = join( + q{ }, + $CONFIG{terminal}, + sprintf( + $CONFIG{terminal_exec}, + join(q{ }, + $CONFIG{pipe_viewer}, get_options_as_arguments(), + @arguments, @{$CONFIG{pipe_viewer_args}}), + ) + ); + my $code = execute_external_program($command); + + say $command if $yv_obj->get_debug; + + warn "pipe-viewer - exit code: $code\n" if $code != 0; + return 1; +} + +sub download_video { + my $code = get_selected_entry_code(type => 'video') // return; + execute_cli_pipe_viewer("--video-id=$code", '--download'); + return 1; +} + +sub comments_row_activated { + + my $iter = $feeds_treeview->get_selection->get_selected() or return; + my $url = $feeds_liststore->get($iter, 1); + + if (defined($url) and $url =~ m{^https?://}) { # load more comments + + my $token = $feeds_liststore->get($iter, 2); + $feeds_liststore->remove($iter); + my $results = $yv_obj->next_page_with_token($url, $token); + + if ($yv_utils->has_entries($results)) { + display_comments($results); + } + else { + die "This is the last page of comments.\n"; + } + + return 1; + } + + my $video_id = $feeds_liststore->get($iter, 3); + my $comment_id = $feeds_liststore->get($iter, 4); + + my $comment_url = sprintf("https://www.youtube.com/watch?v=%s&lc=%s", $video_id, $comment_id,); + + open_external_url($comment_url); + + return 1; +} + +sub show_user_favorited_videos { + my $username = get_channel_id_for_selected_video() // return; + favorites('channel', $username); +} + +sub get_channel_id_for_selected_video { + my $selection = $treeview->get_selection() // return; + my $iter = $selection->get_selected() // return; + $liststore->get($iter, 6); +} + +sub show_related_videos { + my $video_id = get_selected_entry_code(type => 'video') // return; + + my $results = $yv_obj->related_to_videoID($video_id); + if ($yv_utils->has_entries($results)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($results); + } + else { + die "No related video for videoID: <$video_id>\n"; + } +} + +sub send_comment_to_video { + my $videoID = get_selected_entry_code(type => 'video') // return; + my $comment = get_text($gui->get_object('comment_textview')); + + $feeds_statusbar->push(0, + length($comment) && $yv_obj->comment_to_video_id($comment, $videoID) + ? 'Video comment has been posted!' + : 'Error!'); +} + +sub wrap_text { + my (%args) = @_; + + require Text::Wrap; + local $Text::Wrap::columns = $CONFIG{comments_width}; + + my $text = "@{$args{text}}"; + $text =~ tr{\r}{}d; + + eval { Text::Wrap::wrap($args{i_tab}, $args{s_tab}, $text) } // $text; +} + +sub display_comments { + my ($results) = @_; + + return 1 if ref($results) ne 'HASH'; + + my $url = $results->{url}; + my $video_id = $results->{results}{videoId}; + my $comments = $results->{results}{comments} // []; + my $continuation = $results->{results}{continuation}; + + 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( + wrap_text( + i_tab => "\t", + s_tab => "\t", + text => [$yv_utils->get_comment_content($comment) // 'Empty comment...'], + ) + ) + ); + + my $iter = $feeds_liststore->append; + $feeds_liststore->set( + $iter, + 0 => $comment_text, + 3 => $video_id, + 4 => $comment_id, + ); + + #~ if (exists $comment->{replies}) { + #~ foreach my $reply (reverse @{$comment->{replies}{comments}}) { + #~ my $reply_age = $yv_utils->date_to_age($reply->{snippet}{publishedAt}); + #~ my $reply_text = reflow_text( + #~ "\t" + #~ . encode_entities($reply->{snippet}{authorDisplayName}) + #~ . " (" + #~ . ( + #~ $reply_age =~ /sec|min|hour|day/ + #~ ? "$reply_age ago" + #~ : $yv_utils->format_date($reply->{snippet}{publishedAt}) + #~ ) + #~ . ") replied:\n" + #~ . encode_entities( + #~ wrap_text( + #~ i_tab => "\t\t", + #~ s_tab => "\t\t", + #~ text => [$reply->{snippet}{textDisplay} // 'Empty comment...'] + #~ ) + #~ ) + #~ ); + + #~ my $iter = $feeds_liststore->append; + #~ $feeds_liststore->set( + #~ $iter, + #~ 0 => $reply_text, + #~ 3 => $reply->{snippet}{videoId}, + #~ 4 => $reply->{id}, + #~ ); + #~ } + #~ } + } + + if (defined $continuation) { + my $iter = $feeds_liststore->append; + $feeds_liststore->set( + $iter, + 0 => "LOAD MORE", + 1 => $url, + 2 => $continuation, + ); + } + + return 1; +} + +sub save_session { + $CONFIG{remember_session} || return; + + my $curr = $ResultsHistory{current}; + my $curr_result = $ResultsHistory{results}[$curr] // return; + + my @results = @{$ResultsHistory{results}}; + + require List::Util; + + my $max = $CONFIG{remember_session_depth}; + my @left = @results[List::Util::max(0, $curr - $max) .. $curr - 1]; + my @right = @results[$curr + 1 .. List::Util::min($#results, $curr + $max)]; + + if ($yv_obj->get_debug) { + say "Session total: ", scalar(@results); + say "Session left : ", scalar(@left); + say "Session right: ", scalar(@right); + } + + $ResultsHistory{current} = $#left + 1; + $ResultsHistory{results} = [@left, $curr_result, @right]; + + require Storable; + Storable::store( + { + keyword => $search_entry->get_text, + history => \%ResultsHistory, + }, + $session_file + ); +} + +sub add_results_to_history { + my ($results) = @_; + my $results_copy = $results; + $ResultsHistory{current}++; + splice @{$ResultsHistory{results}}, $ResultsHistory{current}, 0, $results_copy; + set_prev_next_results_sensitivity(); +} + +sub display_previous_results { + if ($ResultsHistory{current} > 0) { + $ResultsHistory{current}--; + display_relative_results($ResultsHistory{current}); + } +} + +sub display_next_results { + if ($ResultsHistory{current} < $#{$ResultsHistory{results}}) { + $ResultsHistory{current}++; + display_relative_results($ResultsHistory{current}); + } +} + +sub display_relative_results { + my ($nth_item) = @_; + $liststore->clear if $CONFIG{clear_search_list}; + my $results_copy = $ResultsHistory{results}[$nth_item]; + display_results($results_copy, 1); + set_prev_next_results_sensitivity(); +} + +sub set_prev_next_results_sensitivity { + $gui->get_object('show_prev_results')->set_sensitive($ResultsHistory{current} > 0); + $gui->get_object('show_next_results')->set_sensitive($ResultsHistory{current} < $#{$ResultsHistory{results}}); +} + +sub show_videos_from_selected_author { + uploads('channel', get_channel_id_for_selected_video() || return); +} + +sub show_playlists_from_selected_author { + my $request = $yv_obj->playlists(get_channel_id_for_selected_video() || return); + if ($yv_utils->has_entries($request)) { + $liststore->clear if $CONFIG{clear_search_list}; + display_results($request); + } + else { + die "No playlists found...\n"; + } + return 1; +} + +sub set_entry_details { + my ($code, $iter) = @_; + + my $type = $liststore->get($iter, 7); + my $main_details = $liststore->get($iter, 0); + my $channel_id = get_channel_id_for_selected_video(); + + # 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"); + + # 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; + + 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"; + + my $text_info = join("\n", grep { !/^&#\w+;$/ } split(/\R/, "$main_details$secondary_details")); + $gui->get_object('video_details_label')->set_label($text_info); + + # Setting the link button + my $url = make_youtube_url($type, $code); + my $linkbutton = $gui->get_object('linkbutton1'); + + $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, + end => 3, + ); + + # Getting thumbs + foreach my $type (keys %thumbs) { + + $gui->get_object("image$thumbs{$type}")->set_from_pixbuf($default_thumb); + + Glib::Idle->add( + sub { + my ($type) = @{$_[0]}; + + my $url = $yv_utils->get_thumbnail_url($info, $type); + + #~ my $thumbnail = $info->{snippet}{thumbnails}{medium}; + #~ my $url = $thumbnail->{url}; + + if ($url =~ /_live\.\w+\z/) { + ## no extra thumbnails available while video is LIVE + } + else { + $url =~ s{/\w+\.(\w+)\z}{/mq$thumbs{$type}.$1}; + } + + my $pixbuf = get_pixbuf_thumbnail_from_url($url, 160, 90); + $gui->get_object("image$thumbs{$type}")->set_from_pixbuf($pixbuf); + + return 0; + }, + [$type], + Glib::G_PRIORITY_DEFAULT_IDLE + ); + } + + # Setting textview description + set_text($gui->get_object('description_textview'), decode_entities($liststore->get($iter, 4))); + return 1; +} + +sub on_mainw_destroy { + + # Save hpaned position + $CONFIG{hpaned_position} = $hbox2->get_position; + + get_main_window_size(); + dump_configuration(); + save_usernames_to_file(); + save_session(); + + 'Gtk3'->main_quit; +} + +$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') { + %ResultsHistory = %{$session->{history}}; + $search_entry->set_text($session->{keyword}); + $search_entry->set_position(length($session->{keyword})); + $search_entry->select_region(0, -1); + + if (not @ARGV) { + Glib::Idle->add( + sub { + display_relative_results($ResultsHistory{current}); + return 0; + }, + [], + Glib::G_PRIORITY_DEFAULT_IDLE + ); + } + } + else { + warn "[!] Failed to load previous session...\n"; + warn "[!] Reason: $@\n" if $@; + } +} + +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 + ); +} + +'Gtk3'->main; diff --git a/bin/pipe-viewer b/bin/pipe-viewer new file mode 100755 index 0000000..776ece9 --- /dev/null +++ b/bin/pipe-viewer @@ -0,0 +1,4532 @@ +#!/usr/bin/perl + +# Copyright (C) 2010-2020 Trizen . +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of either: the GNU General Public License as published +# by the Free Software Foundation; or the Artistic License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# +# See https://dev.perl.org/licenses/ for more information. +# +#------------------------------------------------------- +# pipe-viewer +# Fork: 14 February 2020 +# Edit: 06 October 2020 +# https://github.com/trizen/pipe-viewer +#------------------------------------------------------- + +# pipe-viewer is a command line utility for streaming YouTube videos in mpv/vlc. + +# This is a fork of youtube-viewer: +# https://github.com/trizen/youtube-viewer + +=encoding utf8 + +=head1 NAME + +pipe-viewer - YouTube from command line. + + pipe-viewer --help + pipe-viewer --tricks + pipe-viewer --examples + pipe-viewer --stdin-help + +=cut + +use utf8; +use 5.016; + +use warnings; +no warnings 'once'; + +my $DEVEL; # true in devel mode +use if ($DEVEL = 1), lib => qw(../lib); # devel mode + +use WWW::PipeViewer v0.0.1; +use WWW::PipeViewer::RegularExpressions; + +use File::Spec::Functions qw( + catdir + catfile + curdir + path + rel2abs + tmpdir + file_name_is_absolute + ); + +binmode(STDOUT, ':utf8'); + +my $appname = 'CLI Pipe Viewer'; +my $version = $WWW::PipeViewer::VERSION; +my $execname = 'pipe-viewer'; + +# A better support: +require Term::ReadLine; +my $term = Term::ReadLine->new("$appname $version"); + +# Options (key=>value) goes here +my %opt; +my $term_width = 80; + +# Keep track of watched videos by their ID +my %watched_videos; + +# Unchangeable data goes here +my %constant = (win32 => ($^O eq 'MSWin32' ? 1 : 0)); # doh + +my $home_dir; +my $xdg_config_home = $ENV{XDG_CONFIG_HOME}; + +if ($xdg_config_home and -d -w $xdg_config_home) { + require File::Basename; + $home_dir = File::Basename::dirname($xdg_config_home); + + if (not -d -w $home_dir) { + $home_dir = $ENV{HOME} || curdir(); + } +} +else { + $home_dir = + $ENV{HOME} + || $ENV{LOGDIR} + || ($constant{win32} ? '\Local Settings\Application Data' : ((getpwuid($<))[7] || `echo -n ~`)); + + if (not -d -w $home_dir) { + $home_dir = curdir(); + } + + $xdg_config_home = catdir($home_dir, '.config'); +} + +# Configuration dir/file +my $config_dir = catdir($xdg_config_home, $execname); +my $config_file = catfile($config_dir, "$execname.conf"); +my $authentication_file = catfile($config_dir, 'reg.dat'); +my $history_file = catfile($config_dir, 'cli-history.txt'); +my $watched_file = catfile($config_dir, 'watched.txt'); +my $api_file = catfile($config_dir, 'api.json'); + +if (not -d $config_dir) { + require File::Path; + File::Path::make_path($config_dir) + or warn "[!] Can't create dir '$config_dir': $!"; +} + +sub which_command { + my ($cmd) = @_; + + if (file_name_is_absolute($cmd)) { + return $cmd; + } + + state $paths = [path()]; + foreach my $path (@{$paths}) { + my $cmd_path = catfile($path, $cmd); + if (-f -x $cmd_path) { + return $cmd_path; + } + } + + return; +} + +# Main configuration +my %CONFIG = ( + + video_players => { + vlc => { + cmd => q{vlc}, + srt => q{--sub-file=*SUB*}, + audio => q{--input-slave=*AUDIO*}, + fs => q{--fullscreen}, + arg => q{--quiet --play-and-exit --no-video-title-show --input-title-format=*TITLE*}, + novideo => q{--intf=dummy --novideo}, + }, + mpv => { + cmd => q{mpv}, + srt => q{--sub-file=*SUB*}, + audio => q{--audio-file=*AUDIO*}, + fs => q{--fullscreen}, + arg => q{--really-quiet --force-media-title=*TITLE* --no-ytdl}, + novideo => q{--no-video}, + }, + }, + + video_player_selected => ( + $constant{win32} + ? 'vlc' + : undef # auto-defined + ), + + # YouTube options + dash_support => 1, + dash_mp4_audio => 1, + dash_segmented => 1, # may load slow + maxResults => 20, + hfr => 1, # true to prefer high frame rate (HFR) videos + resolution => 'best', + videoDefinition => undef, + videoDimension => undef, + videoLicense => undef, + videoCaption => undef, + videoDuration => undef, + + order => undef, + date => undef, + + comments_order => 'top', # valid values: top, new + subscriptions_order => 'relevance', # valid values: alphabetical, relevance, unread + + region => undef, + + # URI options + youtube_video_url => 'https://www.youtube.com/watch?v=%s', + + # Subtitle options + srt_languages => ['en', 'es'], + get_captions => 1, + auto_captions => 0, + copy_caption => 0, + cache_dir => undef, + + # 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, + + # youtube-dl support + ytdl => 1, + ytdl_cmd => undef, # auto-defined + + custom_layout => undef, # auto-defined + custom_layout_format => [{width => 3, align => "right", color => "bold", text => "*NO*.",}, + {width => "55%", align => "left", color => "bold blue", text => "*TITLE*",}, + {width => "15%", align => "left", color => "yellow", text => "*AUTHOR*",}, + {width => 3, align => "right", color => "green", text => "*AGE_SHORT*",}, + {width => 5, align => "right", color => "green", text => "*VIEWS_SHORT*",}, + {width => 8, align => "right", color => "blue", text => "*TIME*",}, + ], + + ffmpeg_cmd => 'ffmpeg', + wget_cmd => 'wget', + + merge_into_mkv => undef, # auto-defined later + merge_into_mkv_args => '-loglevel warning -c:s srt -c:v copy -c:a copy -disposition:s forced', + merge_with_captions => 1, + + video_filename_format => '*FTITLE* - *ID*.*FORMAT*', +); + +local $SIG{__WARN__} = sub { warn @_; ++$opt{_error} }; + +my %MPLAYER; # will store video player arguments + +my $base_options = <<'BASE'; +# Base +[keywords] : search for YouTube videos +[youtube-url] : play a video by YouTube URL +:v(ideoid)=ID : play videos by YouTube video IDs +[playlist-url] : display videos from a playlistURL +:playlist=ID : display videos from a playlistID +BASE + +my $action_options = <<'ACTIONS'; +# Actions +:login : will prompt you for login +:logout : will delete the authentication key +ACTIONS + +my $control_options = <<'CONTROL'; +# Control +:n(ext) : get the next page of results +:b(ack) : get the previous page of results +CONTROL + +my $other_options = <<'OTHER'; +# Others +:r(eturn) : return to previous page of results +:refresh : refresh the current list of results +:dv=i : display the data structure of result i +-argv -argv2=v : apply some arguments (e.g.: -u=google) +:reset, :reload : restart the application +:q, :quit, :exit : close the application +OTHER + +my $notes_options = <<'NOTES'; +NOTES: + 1. You can specify more options in a row, separated by spaces. + 2. A stdin option is valid only if it begins with '=', ';' or ':'. + 3. Quoting a group of space separated keywords or option-values, + the group will be considered a single keyword or a single value. +NOTES + +my $general_help = <<"HELP"; + +$action_options +$control_options +$other_options +$notes_options +Examples: + 3 : select the 3rd result + -sv funny cats : search for videos + -sc mathematics : search for channels + -sp classical music : search for playlists +HELP + +my $playlists_help = <<"PL_HELP" . $general_help; + +# Playlists +:pp=i,i : play videos from the selected playlists +PL_HELP + +my $comments_help = <<"COM_HELP" . $general_help; + +# Comments +:c(omment) : send a comment to this video +COM_HELP + +my $complete_help = <<"STDIN_HELP"; + +$base_options +$control_options +$action_options +# YouTube +:i(nfo)=i,i : display more information +:d(ownload)=i,i : download the selected videos +:c(omments)=i : display video comments +:r(elated)=i : display related videos +:u(ploads)=i : display author's latest uploads +:pv=i :popular=i : display author's popular uploads +:A(ctivity)=i : display author's recent activity +:p(laylists)=i : display author's playlists +:ps=i :s2p=i,i : save videos to a post-selected playlist +:subscribe=i : subscribe to author's channel +:(dis)like=i : like or dislike a video +:fav(orite)=i : favorite a video +:autoplay=i : autoplay mode, starting from video i + +# Playing + : play the corresponding video +3-8, 3..8 : same as 3 4 5 6 7 8 +8-3, 8..3 : same as 8 7 6 5 4 3 +8 2 12 4 6 5 1 : play the videos in a specific order +10.. : play all the videos onwards from 10 +:q(ueue)=i,i,... : enqueue videos for playing them later +:pq, :play-queue : play the enqueued videos (if any) +:anp, :nnp : auto-next-page, no-next-page +:play=i,i,... : play a group of selected videos +:regex=my?[regex] : play videos matched by a regex (/i) +:kregex=KEY,RE : play videos if the value of KEY matches the RE + +$other_options +$notes_options +** Examples: +:regex="\\w \\d" -> play videos matched by a regular expression. +:info=1 -> show extra information for the first video. +:d18-20,1,2 -> download the selected videos: 18, 19, 20, 1 and 2. +3 4 :next 9 -> play the 3rd and 4th videos from the current + page, go to the next page and play the 9th video. +STDIN_HELP + +{ + my $config_documentation = <<"EOD"; +#!/usr/bin/perl + +# $appname $version - configuration file + +EOD + + sub dump_configuration { + my ($config_file) = @_; + + require Data::Dump; + open my $config_fh, '>', $config_file + or do { warn "[!] Can't open '${config_file}' for write: $!"; return }; + + my $dumped_config = q{our $CONFIG = } . Data::Dump::pp(\%CONFIG) . "\n"; + + if (defined($ENV{HOME}) and $home_dir eq $ENV{HOME}) { + $dumped_config =~ s/\Q$home_dir\E/\$ENV{HOME}/g; + } + + print $config_fh $config_documentation, $dumped_config; + close $config_fh; + } +} + +our $CONFIG; + +sub load_config { + my ($config_file) = @_; + + if (not -e $config_file or -z _ or $opt{reconfigure}) { + dump_configuration($config_file); + } + + require $config_file; # Load the configuration file + + if (ref $CONFIG ne 'HASH') { + die "[ERROR] Invalid configuration file!\n\t\$CONFIG is not an HASH ref!"; + } + + # Get valid config keys + my @valid_keys = grep { exists $CONFIG{$_} } keys %{$CONFIG}; + @CONFIG{@valid_keys} = @{$CONFIG}{@valid_keys}; + + my $update_config = 0; + + # Define the cache directory + if (not defined $CONFIG{cache_dir}) { + + my $cache_dir = + ($ENV{XDG_CACHE_HOME} and -d -w $ENV{XDG_CACHE_HOME}) + ? $ENV{XDG_CACHE_HOME} + : catdir($home_dir, '.cache'); + + if (not -d -w $cache_dir) { + $cache_dir = catdir(curdir(), '.cache'); + } + + $CONFIG{cache_dir} = catdir($cache_dir, 'pipe-viewer'); + $update_config = 1; + } + + # Locating a video player + if (not $CONFIG{video_player_selected}) { + + foreach my $key (sort keys %{$CONFIG{video_players}}) { + if (defined(my $abs_player_path = which_command($CONFIG{video_players}{$key}{cmd}))) { + $CONFIG{video_players}{$key}{cmd} = $abs_player_path; + $CONFIG{video_player_selected} = $key; + $update_config = 1; + last; + } + } + + if (not $CONFIG{video_player_selected}) { + warn "\n[!] Please install a supported video player! (e.g.: mpv)\n\n"; + $CONFIG{video_player_selected} = 'mpv'; + } + } + + # Locate youtube-dl + if (not defined($CONFIG{ytdl_cmd})) { + + my $ytdl_path = which_command('youtube-dl'); + + if (defined($ytdl_path)) { + $CONFIG{ytdl_cmd} = $ytdl_path; + } + else { + $CONFIG{ytdl_cmd} = 'youtube-dl'; + } + + $update_config = 1; + } + + # Download with wget if it is installed + if (not defined $CONFIG{download_with_wget}) { + + my $wget_path = which_command('wget'); + + if (defined($wget_path)) { + $CONFIG{wget_cmd} = $wget_path; + $CONFIG{download_with_wget} = 1; + } + else { + $CONFIG{download_with_wget} = 0; + } + + $update_config = 1; + } + + # Merge into MKV if ffmpeg is installed + if (not defined $CONFIG{merge_into_mkv}) { + + my $ffmpeg_path = which_command('ffmpeg'); + + if (defined($ffmpeg_path)) { + $CONFIG{ffmpeg_cmd} = $ffmpeg_path; + $CONFIG{merge_into_mkv} = 1; + } + else { + $CONFIG{merge_into_mkv} = 0; + } + + $update_config = 1; + } + + # Fixed-width format and custom layout + if ( not defined($CONFIG{results_fixed_width}) + or not defined($CONFIG{custom_layout})) { + + if ( eval { require Unicode::GCString; 1 } + or eval { require Text::CharWidth; 1 }) { + $CONFIG{custom_layout} //= 1; + $CONFIG{results_fixed_width} //= 1; + } + else { + $CONFIG{custom_layout} = 0; + $CONFIG{results_fixed_width} = 0; + } + + $update_config = 1; + } + + # Enable history if Term::ReadLine::Gnu::XS is installed + if (not defined $CONFIG{history}) { + + if (eval { $term->can('ReadHistory') }) { + $CONFIG{history} = 1; + } + else { + $CONFIG{history} = 0; + } + + $update_config = 1; + } + + foreach my $key (keys %CONFIG) { + if (not exists $CONFIG->{$key}) { + $update_config = 1; + last; + } + } + + dump_configuration($config_file) if $update_config; + + foreach my $path ($CONFIG{cache_dir}) { + next if -d $path; + require File::Path; + File::Path::make_path($path) + or warn "[!] Can't create path <<$path>>: $!"; + } + + @opt{keys %CONFIG} = values(%CONFIG); +} + +load_config($config_file); + +if ($opt{remember_watched}) { + if (-f $opt{watched_file}) { + if (open my $fh, '<', $opt{watched_file}) { + chomp(my @ids = <$fh>); + @watched_videos{@ids} = (); + close $fh; + } + else { + warn "[!] Can't open the watched file `$opt{watched_file}' for reading: $!"; + } + } +} + +if ($opt{history}) { + + # Create the history file. + if (not -e $opt{history_file}) { + + require File::Basename; + my $dir = File::Basename::dirname($opt{history_file}); + + if (not -d $dir) { + require File::Path; + File::Path::make_path($dir) + or warn "[!] Can't create path <<$dir>>: $!"; + } + + open my $fh, '>', $opt{history_file} + or warn "[!] Can't create the history file `$opt{history_file}': $!"; + } + + # Add history to Term::ReadLine + eval { $term->ReadHistory($opt{history_file}) }; + + # All history entries + my @history = $term->history_list; + + # Rewrite the history file, when the history_limit has been reached. + if ($opt{history_limit} > 0 and @history > $opt{history_limit}) { + + # Try to create a backup, first + require File::Copy; + File::Copy::cp($opt{history_file}, "$opt{history_file}.bak"); + + if (open my $fh, '>', $opt{history_file}) { + + # Keep only the most recent half part of the history file + say {$fh} join("\n", @history[($opt{history_limit} >> 1) .. $#history]); + close $fh; + } + } +} + +my $yv_obj = WWW::PipeViewer->new( + escape_utf8 => 1, + config_dir => $config_dir, + ytdl => $opt{ytdl}, + ytdl_cmd => $opt{ytdl_cmd}, + cache_dir => $opt{cache_dir}, + env_proxy => $opt{env_proxy}, + cookie_file => $opt{cookie_file}, + http_proxy => $opt{http_proxy}, + user_agent => $opt{user_agent}, + timeout => $opt{timeout}, + ); + +require WWW::PipeViewer::Utils; +my $yv_utils = WWW::PipeViewer::Utils->new(youtube_url_format => $opt{youtube_video_url}, + thousand_separator => $opt{thousand_separator},); + +{ # Apply the configuration file + my %temp = %CONFIG; + apply_configuration(\%temp); +} + +#---------------------- PIPE-VIEWER USAGE ----------------------# +sub help { + my $eqs = q{=} x 30; + + local $" = ', '; + print <<"HELP"; +\n $eqs \U$appname\E $eqs + +usage: $execname [options] ([url] | [keywords]) + +== Base == + [URL] : play an YouTube video by URL + [keywords] : search for YouTube videos + [playlist URL] : display a playlist of YouTube videos + + +== YouTube Options == + + * Categories + -c --categories : display the available YouTube categories + + * Region + --region=s : set the region code (default: US) + + * Videos + -uv --uploads=s : list videos uploaded by a specific channel or user + -pv --popular=s : list the most popular videos from a specific channel + -uf --favorites=s : list the favorite videos of a specific user + -id --videoids=s,s : play YouTube videos by their IDs + -rv --related=s : show related videos for a video ID or URL + -sv --search-videos : search for YouTube videos (default mode) + + * Playlists + -up --playlists=s : list playlists created by a specific channel or user + -sp --search-pl : search for playlists of videos + --pid=s : list a playlist of videos by playlist ID + --pp=s,s : play the videos from the given playlist IDs + --ps=s : add video by ID or URL to a post-selected playlist + or in a given playlist ID specified with `--pid` + --position=i : position in the playlist where to add the video + +* Activities + -ua --activities:s : show activity events for a given channel + +* Trending + --trending:s : show trending videos in a given category + valid categories: music gaming news movies popular + + * Channels + -sc --channels : search for YouTube channels + +* Comments + --comments=s : display comments for a video by ID or URL + --comments-order=s : change the order of YouTube comments + valid values: relevance, time + + * Filtering + --author=s : search in videos uploaded by a specific user + --duration=s : filter search results based on video length + valid values: short long + --captions! : only videos with or without closed captions + --order=s : order the results using a specific sorting method + valid values: relevance rating upload_date view_count + --date=s : short videos published in a time period + valid values: hour today week month year + --hd! : search only for videos available in at least 720p + --vd=s : set the video definition (any or high) + --dimension=s : set video dimension (any or 3d) + --license=s : set video license (any or creative_commons) + --page=i : get results starting with a specific page number + --results=i : how many results to display per page (max: 50) + --hfr! : prefer high frame rate (HFR) videos + -2 -3 -4 -7 -1 : resolutions: 240p, 360p, 480p, 720p and 1080p + --resolution=s : supported resolutions: best, 2160p, 1440p, + 1080p, 720p, 480p, 360p, 240p, 144p, audio. + + * Account + --login : will prompt for authentication (OAuth 2.0) + --logout : will delete the authentication key + + * [GET] Personal + -U --uploads:s : show the uploads from your channel * + -P --playlists:s : show the playlists from your channel * + -F --favorites:s : show the latest favorited videos * + -S --subscriptions:s : show the subscribed channels * + -SV --subs-videos:s : show the subscription videos (slow) * + --subs-order=s : change the subscription order + valid values: alphabetical, relevance, unread + -L --likes : show the videos that you liked on YouTube * + --dislikes : show the videos that you disliked on YouTube * + +* [POST] Personal + --subscribe=s : subscribe to a given channel ID or username * + --favorite=s : favorite a video by URL or ID * + --like=s : send a 'like' rating to a video URL or ID * + --dislike=s : send a 'dislike' rating to a video URL or ID * + + +== Player Options == + + * Arguments + -f --fullscreen! : play videos in fullscreen mode + -n --audio! : play audio only, without displaying video + --append-arg=s : append some command-line parameters to the media player + --player=s : select a player to stream videos + available players: @{[keys %{$CONFIG->{video_players}}]} + + +== Download Options == + + * Download + -d --download! : activate the download mode + -dp --dl-play! : play the video after download (with -d) + -rp --rem-played! : delete a local video after played (with -dp) + --wget-dl! : download videos with wget (recommended) + --skip-if-exists! : don't download videos which already exist (with -d) + --copy-caption! : copy and rename the caption for downloaded videos + --downloads-dir=s : downloads directory (set: '$opt{downloads_dir}') + --filename=s : set a custom format for the video filename (see: -T) + --fat32safe! : makes filenames FAT32 safe (includes Unicode) + --mkv-merge! : merge audio and video into an MKV container + --merge-captions! : include closed-captions in the MKV container + + * Convert + --convert-cmd=s : command for converting videos after download + which include the *IN* and *OUT* tokens + --convert-to=s : convert video to a specific format (with -d) + --keep-original! : keep the original video after converting + + +== Other Options == + + * Behavior + -A --all! : play the video results in order + -B --backwards! : play the video results in reverse order + -s --shuffle! : shuffle the results of videos + -I --interactive! : interactive mode, prompting for user input + --autoplay! : autoplay mode, automatically playing related videos + --std-input=s : use this value as the first standard input + --max-seconds=i : ignore videos longer than i seconds + --min-seconds=i : ignore videos shorter than i seconds + --get-term-width! : allow $execname to read your terminal width + --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 + --ignore-av1! : ignore videos in AV1 format + + * Closed-captions + --get-captions! : download closed-captions for videos + --auto-captions! : include or exclude auto-generated captions + + * Config + --config=s : configuration file + --update-config! : update the configuration file + + * Output + -C --colorful! : use colors to delimit the video results + -D --details! : display the results with extra details + -W --fixed-width! : adjust the results to fit inside the term width + --custom-layout! : display the results using a custom layout (see conf) + -i --info=s : show information for a video ID or URL + -e --extract=s : extract information from videos (see: -T) + --extract-file=s : extract information from videos in this file + --dump=format : dump metadata information in `videoID.format` files + valid formats: json, perl + -q --quiet : do not display any warning + --really-quiet : do not display any warning or output + --video-info! : show video information before playing + --escape-info! : quotemeta() the fields of the `--extract` + --use-colors! : enable or disable the ANSI colors for text + + * Other + --api=s : set an API host from https://instances.invidio.us/ + --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 + --ytdl! : use youtube-dl for videos with encrypted signatures + `--no-ytdl` will use invidious instances + --ytdl-cmd=s : youtube-dl command (default: youtube-dl) + +Help options: + -T --tricks : show more 'hidden' features of $execname + -E --examples : show several usage examples of $execname + -H --stdin-help : show the valid stdin options for $execname + -v --version : print version and exit + -h --help : print help and exit + --debug:1..3 : see behind the scenes + +NOTES: + * -> requires authentication + ! -> the argument can be negated with '--no-' + =i -> requires an integer argument + =s -> requires an argument + :s -> can take an optional argument + =s,s -> can take more arguments separated by commas + +HELP + main_quit(0); +} + +sub wrap_text { + my (%args) = @_; + + require Text::Wrap; + local $Text::Wrap::columns = ($args{columns} || $term_width) - 8; + + my $text = "@{$args{text}}"; + $text =~ tr{\r}{}d; + + return eval { Text::Wrap::wrap($args{i_tab}, $args{s_tab}, $text) } // $text; +} + +sub tricks { + print <<"TRICKS"; + + == pipe-viewer -- tips and tricks == + +-> Playing videos + > To stream the videos in other players, you need to change the + configuration file. Where it says "video_player_selected", change it + to any player which is defined inside the "video_players" hash. + +-> Arguments + > Almost all boolean arguments can be negated with a "--no-" prefix. + > Arguments that require an ID/URL, you can specify more than one, + separated by whitespace (quoted), or separated by commas. + +-> My channel + > By using the string "mine" where a channel ID is required, "mine" will be + automatically replaced with your channel ID. (requires authentication) + + Examples: + $execname --uploads=mine + $execname --likes=mine + $execname --favorites=mine + $execname --playlists=mine + +-> More STDIN help: + > ":r", ":return" will return to the previous page of results. + For example, if you search for playlists, then select a playlist + of videos, inserting ":r" will return back to the playlist results. + Also, for the previous page, you can insert ':b', but ':r' is faster! + + > "6" (quoted) will search for videos with the keyword '6'. + + > If a stdin option is followed by one or more digits, the equal sign, + which separates the option from value, can be omitted. + + Example: + :i2,4 is equivalent with :i=2,4 + :d1-5 is equivalent with :d=1,2,3,4,5 + :c10 is equivalent with :c=10 + + > When more videos are selected to play, you can stop them by + pressing CTRL+C. $execname will return to the previous section. + + > Space inside the values of STDIN options, can be either quoted + or backslashed. + + Example: + :re=video\\ title == :re="video title" + + > ":anp" stands for "Auto Next Page". How do we use it? + Well, let's search for some videos. Now, if we want to play + only the videos matched by a regex, we'd say :re="REGEX". + But, what if we want to play the videos from the next pages too? + In this case, ":anp" is your friend. Use it wisely! + +-> Special tokens: + + *ID* : the YouTube video ID + *AUTHOR* : the author name of the video + *CHANNELID* : the channel ID of the video + *RESOLUTION* : the resolution of the video + *VIEWS* : the number of views + *VIEWS_SHORT* : the number of views in abbreviated notation + *LIKES* : the number of likes + *DISLIKES* : the number of dislikes + *RATING* : the rating of the video from 0 to 5 + *COMMENTS* : the number of comments + *DURATION* : the duration of the video in seconds + *PUBLISHED* : the publication date as "DD MMM YYYY" + *AGE* : the age of a video (N days, N months, N years) + *AGE_SHORT* : the abbreviated age of a video (Nd, Nm, Ny) + *DIMENSION* : the dimension of the video (2D or 3D) + *DEFINITION* : the definition of the video (HD or SD) + *TIME* : the duration of the video as "HH::MM::SS" + *TITLE* : the title of the video + *FTITLE* : the title of the video (filename safe) + *DESCRIPTION* : the description of the video + + *URL* : the YouTube URL of the video + *ITAG* : the itag value of the video + *FORMAT* : the extension of the video (without the dot) + + *CAPTION* : true if the video has captions + *SUB* : the local subtitle file (if any) + *AUDIO* : the audio URL of the video (only in DASH mode) + *VIDEO* : the video URL of the video (it might not contain audio) + *AOV* : audio URL (if any) or video URL (in this order) + +-> Special escapes: + \\t tab + \\n newline + \\r return + \\f form feed + \\b backspace + \\a alarm (bell) + \\e escape + +-> Extracting information from videos: + > Extracting information can be achieved by using the "--extract" command-line + option which takes a given format as its argument, which is defined by using + special tokens, special escapes or literals. + + Example: + $execname --no-interactive --extract '*TITLE* (*ID*)' [URL] + +-> Configuration file: $config_file + +-> Donations gladly accepted: + https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=75FUVBE6Q73T8 + +TRICKS + main_quit(0); +} + +sub examples { + print <<"EXAMPLES"; +==== COMMAND LINE EXAMPLES ==== + +Command: $execname -A -n russian music -category=10 +Results: play all the video results (-A) + only audio, no video (-n) + search for "russian music" + in category "10", which is the Music category. + -A will include the videos from the next pages as well. + +Command: $execname --comments 'https://www.youtube.com/watch?v=U6_8oIPFREY' +Results: show video comments for a specific video URL or videoID. + +Command: $execname --results=5 -up=khanacademy -D +Results: the most recent 5 videos by a specific author (-up), printed with extra details (-D). + +Command: $execname -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 +Results: search for YouTube channels. + +Command: $execname -uf=Google +Results: show latest videos favorited by a user. + + +==== USER INPUT EXAMPLES ==== + +A STDIN option can begin with ':', ';' or '='. + +Command: , :n, :next +Results: get the next page of results. + +Command: :b, :back (:r, :return) +Results: get the previous page of results. + +Command: :i4..6, :i7-9, :i20-4, :i2, :i=4, :info=4 +Results: show extra information for the selected videos. + +Command: :d5,2, :d=3, :download=8 +Results: download the selected videos. + +Command: :c2, :comments=4 +Results: show comments for a selected video. + +Command: :r4, :related=6 +Results: show related videos for a selected video. + +Command: :a14, :author=12 +Results: show videos uploaded by the author who uploaded the selected video. + +Command: :p9, :playlists=14 +Results: show playlists created by the author who uploaded the selected video. + +Command: :subscribe=7 +Results: subscribe to the author's channel who uploaded the selected video. + +Command: :like=2, :dislike=4,5 +Results: like or dislike the selected videos. + +Command: :fav=4, :favorite=3..5 +Results: favorite the selected videos. + +Command: 3, 5..7, 12-1, 9..4, 2 3 9 +Results: play the selected videos. + +Command: :q3,5, :q=4, :queue=3-9 +Results: enqueue the selected videos to play them later. + +Command: :pq, :play-queue +Results: play the videos enqueued by the :queue option. + +Command: :re="^Linux" +Results: play videos matched by a regex. +Example: matches title: "Linux video" + +Command: :regex="linux.*part \\d+/\\d+" +Example: matches title: "Introduction to Linux (part 1/4)" + +Command: :anp 1 2 3 +Results: play the first three videos from every page. + +Command: :r, :return +Results: return to the previous section. +EXAMPLES + main_quit(0); +} + +sub stdin_help { + print $complete_help; + main_quit(0); +} + +# Print version +sub version { + print "$appname $version\n"; + main_quit(0); +} + +sub apply_configuration { + my ($opt, $keywords) = @_; + + if ($yv_obj->get_debug >= 2 or (defined($opt->{debug}) && $opt->{debug} >= 2)) { + require Data::Dump; + say "=>> Options with keywords: <@{$keywords}>"; + Data::Dump::pp($opt); + } + + # ... BASIC OPTIONS ... # + if (delete $opt->{quiet}) { + close STDERR; + } + + if (delete $opt->{really_quiet}) { + close STDERR; + close STDOUT; + } + + # ... YOUTUBE OPTIONS ... # + foreach my $option_name ( + qw( + videoCaption videoDefinition + videoDimension videoDuration + videoLicense + api_host date order + channelId region debug + http_proxy page comments_order + subscriptions_order user_agent + cookie_file timeout ytdl ytdl_cmd + prefer_mp4 prefer_av1 + ) + ) { + + if (defined $opt->{$option_name}) { + my $code = \&{"WWW::PipeViewer::set_$option_name"}; + my $value = delete $opt->{$option_name}; + my $set_value = $yv_obj->$code($value); + + if (not defined($set_value) or $set_value ne $value) { + warn "\n[!] Invalid value <$value> for option <$option_name>\n"; + } + } + } + + if (defined $opt->{hd}) { + $yv_obj->set_videoDefinition(delete($opt->{hd}) ? 'high' : 'any'); + } + + if (defined $opt->{author}) { + my $name = delete $opt->{author}; + if (my $id = extract_channel_id($name)) { + + if (not $yv_utils->is_channelID($id)) { + $id = $yv_obj->channel_id_from_username($id) // do { + warn_invalid("username or channel ID", $id); + undef; + }; + } + + if ($id eq 'mine') { + $id = $yv_obj->my_channel_id() // do { + warn_invalid("username or channel ID", $id); + undef; + }; + } + + $yv_obj->set_channelId($id); + } + else { + warn_invalid("username or channel ID", $name); + } + } + + if (defined $opt->{more_results}) { + $yv_obj->set_maxResults(delete($opt->{more_results}) ? 50 : $CONFIG{maxResults}); + } + + if (delete $opt->{authenticate}) { + authenticate(); + } + + if (delete $opt->{logout}) { + logout(); + } + + # ... OTHER OPTIONS ... # + if (defined $opt->{extract_info_file}) { + open my $fh, '>:utf8', delete($opt->{extract_info_file}); + $opt{extract_info_fh} = $fh; + } + + if (defined $opt->{colors}) { + $opt{_colors} = $opt->{colors}; + if (delete $opt->{colors}) { + require Term::ANSIColor; + no warnings 'redefine'; + *colored = \&Term::ANSIColor::colored; + *colorstrip = \&Term::ANSIColor::colorstrip; + } + else { + no warnings 'redefine'; + *colored = sub { $_[0] }; + *colorstrip = sub { $_[0] }; + } + } + + # ... SUBROUTINE CALLS ... # + if (defined $opt->{subscribe}) { + subscribe(split(/[,\s]+/, delete $opt->{subscribe})); + } + + if (defined $opt->{favorite_video}) { + favorite_videos(split(/[,\s]+/, delete $opt->{favorite_video})); + } + + if (defined $opt->{playlist_save}) { + my @ids = split(/[,\s]+/, delete $opt->{playlist_save}); + if (defined $opt->{playlist_id}) { + save_to_playlist(get_valid_playlist_id(delete $opt->{playlist_id}) // (return), @ids); + } + else { + select_and_save_to_playlist(@ids); + } + } + + if (defined $opt->{like_video}) { + rate_videos('like', split(/[,\s]+/, delete $opt->{like_video})); + } + + if (defined $opt->{dislike_video}) { + rate_videos('dislike', split(/[,\s]+/, delete $opt->{dislike_video})); + } + + if (defined $opt->{play_video_ids}) { + get_and_play_video_ids(split(/[,\s]+/, delete $opt->{play_video_ids})); + } + + if (defined $opt->{play_playlists}) { + get_and_play_playlists(split(/[,\s]+/, delete $opt->{play_playlists})); + } + + if (defined $opt->{playlist_id}) { + my $playlistID = get_valid_playlist_id(delete($opt->{playlist_id})) // return; + get_and_print_videos_from_playlist($playlistID); + } + + if (delete $opt->{search_videos}) { + print_videos($yv_obj->search_videos([@{$keywords}])); + } + + if (delete $opt->{search_channels}) { + print_channels($yv_obj->search_channels([@{$keywords}])); + } + + if (delete $opt->{search_playlists}) { + print_playlists($yv_obj->search_playlists([@{$keywords}])); + } + + if (delete $opt->{categories}) { + print_categories($yv_obj->video_categories); + } + + if (defined $opt->{uploads}) { + my $str = delete $opt->{uploads}; + + if ($str) { + if (my $id = extract_channel_id($str)) { + $yv_utils->is_channelID($id) + ? print_videos($yv_obj->uploads($id)) + : print_videos($yv_obj->uploads_from_username($id)); + } + else { + warn_invalid("username or channel ID", $str); + } + } + else { + print_videos($yv_obj->uploads); + } + } + + if (defined $opt->{popular_videos}) { + my $str = delete $opt->{popular_videos}; + + if (my $id = extract_channel_id($str)) { + + if (not $yv_utils->is_channelID($id)) { + $id = $yv_obj->channel_id_from_username($id) // do { + warn_invalid("username or channel ID", $id); + undef; + }; + } + + if ($id eq 'mine') { + $id = $yv_obj->my_channel_id() // do { + warn_invalid("username or channel ID", $id); + undef; + }; + } + + print_videos($yv_obj->popular_videos($id)); + } + else { + warn_invalid("username or channel ID", $str); + } + } + + if (defined $opt->{trending}) { + my $cat_id = delete $opt->{trending}; + print_videos($yv_obj->trending_videos_from_category($cat_id)); + } + + if (defined $opt->{subscriptions}) { + my $str = delete $opt->{subscriptions}; + + if ($str) { + if (my $id = extract_channel_id($str)) { + $yv_utils->is_channelID($id) + ? print_channels($yv_obj->subscriptions($id)) + : print_channels($yv_obj->subscriptions_from_username($id)); + } + else { + warn_invalid("username or channel ID", $str); + } + } + else { + print_channels($yv_obj->subscriptions); + } + } + + if (defined $opt->{subscription_videos}) { + my $str = delete $opt->{subscription_videos}; + + if ($str) { + if (my $id = extract_channel_id($str)) { + $yv_utils->is_channelID($id) + ? print_videos($yv_obj->subscription_videos($id)) + : print_videos($yv_obj->subscription_videos_from_username($id)); + } + else { + warn_invalid("username or channel ID", $str); + } + } + else { + print_videos($yv_obj->subscription_videos); + } + } + + if (defined $opt->{related_videos}) { + get_and_print_related_videos(split(/[,\s]+/, delete($opt->{related_videos}))); + } + + if (defined $opt->{playlists}) { + my $str = delete($opt->{playlists}); + + if ($str) { + if (my $id = extract_channel_id($str)) { + $yv_utils->is_channelID($id) + ? print_playlists($yv_obj->playlists($id)) + : print_playlists($yv_obj->playlists_from_username($id)); + } + else { + warn_invalid("username or channel ID", $str); + warn colored("[+] To search for playlists, try: $0 -sp $str", 'bold yellow') . "\n"; + } + } + else { + print_playlists($yv_obj->my_playlists); + } + } + + if (defined $opt->{favorites}) { + my $str = delete($opt->{favorites}); + + if ($str) { + if (my $id = extract_channel_id($str)) { + $yv_utils->is_channelID($id) + ? print_videos($yv_obj->favorites($id)) + : print_videos($yv_obj->favorites_from_username($id)); + } + else { + warn_invalid("username or channel ID", $str); + } + } + else { + print_videos($yv_obj->favorites); + } + } + + if (defined $opt->{likes}) { + my $str = delete($opt->{likes}); + + if ($str) { + if (my $id = extract_channel_id($str)) { + $yv_utils->is_channelID($id) + ? print_videos($yv_obj->likes($id)) + : print_videos($yv_obj->likes_from_username($id)); + } + else { + warn_invalid("username or channel ID", $str); + } + } + else { + print_videos($yv_obj->my_likes); + } + } + + if (defined $opt->{dislikes}) { + delete $opt->{dislikes}; + print_videos($yv_obj->my_dislikes); + } + + if (defined $opt->{activities}) { + my $str = delete $opt->{activities}; + + if ($str) { + if (my $id = extract_channel_id($str)) { + $yv_utils->is_channelID($id) + ? print_videos($yv_obj->activities($id)) + : print_videos($yv_obj->activities_from_username($id)); + } + else { + warn_invalid("username or channel ID", $str); + } + } + else { + print_videos($yv_obj->my_activities); + } + } + + if (defined $opt->{get_comments}) { + get_and_print_comments(split(/[,\s]+/, delete($opt->{get_comments}))); + } + + if (defined $opt->{print_video_info}) { + get_and_print_video_info(split(/[,\s]+/, delete $opt->{print_video_info})); + } +} + +sub parse_arguments { + my ($keywords) = @_; + + state $x = do { + require Getopt::Long; + Getopt::Long::Configure('no_ignore_case'); + }; + + my %orig_opt = %opt; + my $orig_config_file = "$config_file"; + + Getopt::Long::GetOptions( + + # Main options + 'help|usage|h|?' => \&help, + 'examples|E' => \&examples, + 'stdin-help|shelp|sh|H' => \&stdin_help, + 'tricks|tips|T' => \&tricks, + 'version|v' => \&version, + + 'config=s' => \$config_file, + 'update-config!' => sub { dump_configuration($config_file) }, + + # Resolutions + '240p|2' => sub { $opt{resolution} = 240 }, + '360p|3' => sub { $opt{resolution} = 360 }, + '480p|4' => sub { $opt{resolution} = 480 }, + '720p|7' => sub { $opt{resolution} = 720 }, + '1080p|1' => sub { $opt{resolution} = 1080 }, + + 'hfr!' => \$opt{hfr}, + 'res|resolution=s' => \$opt{resolution}, + + 'comments=s' => \$opt{get_comments}, + 'comments-order=s' => \$opt{comments_order}, + + 'c|categories' => \$opt{categories}, + 'video-ids|videoids|id|ids=s' => \$opt{play_video_ids}, + + 'search-videos|search|sv!' => \$opt{search_videos}, + 'search-channels|channels|sc!' => \$opt{search_channels}, + 'search-playlists|sp|p!' => \$opt{search_playlists}, + + 'subscriptions|S:s' => \$opt{subscriptions}, + 'subs-videos|SV:s' => \$opt{subscription_videos}, + 'subs-order=s' => \$opt{subscriptions_order}, + 'uploads|U|user|user-videos|uv|u:s' => \$opt{uploads}, + 'favorites|F|user-favorites|uf:s' => \$opt{favorites}, + 'playlists|P|user-playlists|up:s' => \$opt{playlists}, + 'activities|activity|ua:s' => \$opt{activities}, + 'likes|L|user-likes|ul:s' => \$opt{likes}, + 'dislikes' => \$opt{dislikes}, + 'subscribe=s' => \$opt{subscribe}, + + 'trending|trends:s' => \$opt{trending}, + 'playlist-id|pid=s' => \$opt{playlist_id}, + + # English-UK friendly + 'favorite|favourite|favorite-video|favourite-video|fav=s' => \$opt{favorite_video}, + + 'login|authenticate' => \$opt{authenticate}, + 'logout' => \$opt{logout}, + + 'related-videos|rv=s' => \$opt{related_videos}, + 'popular-videos|popular|pv=s' => \$opt{popular_videos}, + + 'cookie-file|cookies=s' => \$opt{cookie_file}, + 'user-agent|agent=s' => \$opt{user_agent}, + 'http-proxy|https-proxy|proxy=s' => \$opt{http_proxy}, + + 'r|region|region-code=s' => \$opt{region}, + + 'order|order-by|sort|sort-by=s' => \$opt{order}, + 'date=s' => \$opt{date}, + + 'duration=s' => \$opt{videoDuration}, + + 'max-seconds|max_seconds=i' => \$opt{max_seconds}, + 'min-seconds|min_seconds=i' => \$opt{min_seconds}, + + 'like=s' => \$opt{like_video}, + 'dislike=s' => \$opt{dislike_video}, + 'author=s' => \$opt{author}, + 'all|A|play-all!' => \$opt{play_all}, + 'backwards|B!' => \$opt{play_backwards}, + 'input|std-input=s' => \$opt{std_input}, + 'use-colors|colors|colored!' => \$opt{colors}, + + 'autoplay!' => \$opt{autoplay_mode}, + + 'play-playlists|pp=s' => \$opt{play_playlists}, + 'debug:1' => \$opt{debug}, + 'download|dl|d!' => \$opt{download_video}, + 'dimension=s' => \$opt{videoDimension}, + 'license=s' => \$opt{videoLicense}, + 'vd|video-definition=s' => \$opt{videoDefinition}, + 'hd|high-definition!' => \$opt{hd}, + 'I|interactive!' => \$opt{interactive}, + 'convert-to|convert_to=s' => \$opt{convert_to}, + 'keep-original-video!' => \$opt{keep_original_video}, + 'e|extract|extract-info=s' => \$opt{extract_info}, + 'extract-file=s' => \$opt{extract_info_file}, + 'escape-info!' => \$opt{escape_info}, + + 'dump=s' => sub { + my (undef, $format) = @_; + $opt{dump} = ( + ($format =~ /json/i) ? 'json' : ($format =~ /perl/i) ? 'perl' : do { + warn "[!] Invalid format <<$format>> for option --dump\n"; + undef; + } + ); + }, + + # Set a video player + 'player|vplayer|video-player|video_player=s' => sub { + + if (not exists $opt{video_players}{$_[1]}) { + die "[!] Unknown video player selected: <<$_[1]>>\n"; + } + + $opt{video_player_selected} = $_[1]; + }, + + 'append-arg|append-args=s' => \$MPLAYER{user_defined_arguments}, + + # Others + '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}, + + '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}, + + '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}, + + 'ytdl!' => \$opt{ytdl}, + 'ytdl-cmd=s' => \$opt{ytdl_cmd}, + + 'quiet|q!' => \$opt{quiet}, + 'really-quiet!' => \$opt{really_quiet}, + 'video-info!' => \$opt{show_video_info}, + + 'dp|downl-play|download-and-play|dl-play!' => \$opt{download_and_play}, + + 'thousand-separator=s' => \$opt{thousand_separator}, + 'get-captions|get_captions!' => \$opt{get_captions}, + 'auto-captions|auto_captions!' => \$opt{auto_captions}, + 'copy-caption|copy_caption!' => \$opt{copy_caption}, + 'skip-if-exists|skip_if_exists!' => \$opt{skip_if_exists}, + 'downloads-dir|download-dir=s' => \$opt{downloads_dir}, + 'fat32safe!' => \$opt{fat32safe}, + ) + or warn "[!] Error in command-line arguments!\n"; + + if ($config_file ne $orig_config_file) { # load the config file specified with `--config=s` + ##say ":: Loading config: $config_file"; + $config_file = rel2abs($config_file); + + my %new_opt = %opt; + load_config($config_file); + + foreach my $key (keys %new_opt) { + if ( defined($new_opt{$key}) + and defined($orig_opt{$key}) + and $new_opt{$key} ne $orig_opt{$key}) { + $opt{$key} = $new_opt{$key}; + } + } + } + + apply_configuration(\%opt, $keywords); +} + +# Parse the arguments +if (@ARGV) { + require Encode; + @ARGV = map { Encode::decode_utf8($_) } @ARGV; + parse_arguments(\@ARGV); +} + +for (my $i = 0 ; $i <= $#ARGV ; $i++) { + my $arg = $ARGV[$i]; + + next if chr ord $arg eq q{-}; + + if (youtube_urls($arg)) { + splice(@ARGV, $i--, 1); + } +} + +if (my @keywords = grep chr ord ne q{-}, @ARGV) { + print_videos($yv_obj->search_videos(\@keywords)); +} +elsif ($opt{interactive} and -t) { + first_user_input(); +} +elsif ($opt{interactive} and -t STDOUT and not -t) { + print_videos($yv_obj->search_videos(scalar )); +} +else { + main_quit($opt{_error} || 0); +} + +sub get_valid_video_id { + my ($value) = @_; + + my $id = + $value =~ /$get_video_id_re/ ? $+{video_id} + : $value =~ /$valid_video_id_re/ ? $value + : undef; + + if (not defined $id) { + warn_invalid('videoID', $value); + return; + } + + return $id; +} + +sub get_valid_playlist_id { + my ($value) = @_; + + my $id = + $value =~ /$get_playlist_id_re/ ? $+{playlist_id} + : $value =~ /$valid_playlist_id_re/ ? $value + : undef; + + if (not defined $id) { + warn_invalid('playlistID', $value); + return; + } + + return $id; +} + +sub extract_channel_id { + my ($str) = @_; + + if ($str =~ /$get_channel_videos_id_re/) { + return $+{channel_id}; + } + + if ($str =~ /$get_username_videos_re/) { + return $+{username}; + } + + if ($str =~ /$valid_channel_id_re/) { + return $+{channel_id}; + } + + if ($str =~ /^[-a-zA-Z0-9_]+\z/) { + return $str; + } + + return undef; +} + +sub apply_input_arguments { + my ($args, $keywords) = @_; + + if (@{$args}) { + local @ARGV = @{$args}; + parse_arguments($keywords); + } + + return 1; +} + +# Get term width +sub get_term_width { + return $term_width if $constant{win32}; + $term_width = (-t STDOUT) ? ((split(q{ }, `stty size`))[1] || $term_width) : $term_width; +} + +sub first_user_input { + my @keys = get_input_for_first_time(); + + state $first_input_help = <<"HELP"; + +$base_options +$action_options +$other_options +$notes_options +** Example: + To search for playlists, insert: -p keywords +HELP + + if (scalar(@keys)) { + my @for_search; + foreach my $key (@keys) { + if ($key =~ /$valid_opt_re/) { + + my $opt = $1; + + if (general_options(opt => $opt)) { + ## ok + } + elsif ($opt =~ /^(?:h|help)\z/) { + print $first_input_help; + press_enter_to_continue(); + } + elsif ($opt =~ /^(?:r|return)\z/) { + return; + } + else { + warn_invalid('option', $opt); + print "\n"; + exit 1; + } + } + elsif (youtube_urls($key)) { + ## ok + } + else { + push @for_search, $key; + } + } + + if (scalar(@for_search) > 0) { + print_videos($yv_obj->search_videos(\@for_search)); + } + else { + __SUB__->(); + } + } + else { + __SUB__->(); + } +} + +sub get_quotewords { + require Text::ParseWords; + Text::ParseWords::quotewords(@_); +} + +sub clear_title { + my ($title) = @_; + + $title =~ s/[^\w\s[:punct:]]//g; + $title = join(' ', split(' ', $title)); + + return $title; +} + +# Straight copy of parse_options() from Term::UI +sub _parse_options { + my ($input) = @_; + + my $return = {}; + while ( $input =~ s/(?:^|\s+)--?([-\w]+=(["']).+?\2)(?=\Z|\s+)// + or $input =~ s/(?:^|\s+)--?([-\w]+=\S+)(?=\Z|\s+)// + or $input =~ s/(?:^|\s+)--?([-\w]+)(?=\Z|\s+)//) { + my $match = $1; + + if ($match =~ /^([-\w]+)=(["'])(.+?)\2$/) { + $return->{$1} = $3; + + } + elsif ($match =~ /^([-\w]+)=(\S+)$/) { + $return->{$1} = $2; + + } + elsif ($match =~ /^no-?([-\w]+)$/i) { + $return->{$1} = 0; + + } + elsif ($match =~ /^([-\w]+)$/) { + $return->{$1} = 1; + } + } + + return wantarray ? ($return, $input) : $return; +} + +sub parse_options2 { + my ($input) = @_; + + warn(colored("\n[!] Input with an odd number of quotes: <$input>", 'bold red') . "\n\n") + if $yv_obj->get_debug; + + my ($args, $keywords) = _parse_options($input); + + my @args = + map $args->{$_} eq '0' ? "--no-$_" + : $args->{$_} eq '1' ? "--$_" + : "--$_=$args->{$_}" => keys %{$args}; + + return wantarray ? (\@args, [split q{ }, $keywords]) : \@args; +} + +sub parse_options { + my ($input) = @_; + my (@args, @keywords); + + if (not defined($input) or $input eq q{}) { + return \@args, \@keywords; + } + + foreach my $word (get_quotewords(qr/\s+/, 1, $input)) { + if (chr ord $word eq q{-}) { + push @args, $word; + } + else { + push @keywords, $word; + } + } + + if (not @args and not @keywords) { + return parse_options2($input); + } + + return wantarray ? (\@args, \@keywords) : \@args; +} + +sub get_user_input { + my ($text) = @_; + + if (not $opt{interactive}) { + if (not defined $opt{std_input}) { + return ':return'; + } + } + + my $input = unpack( + 'A*', + defined($opt{std_input}) + ? delete($opt{std_input}) + : ( + do { + my @lines = split(/\R/, $text); + say for @lines[0 .. $#lines - 1]; + $term->readline($lines[-1]); + } + // return ':return' + ) + ) =~ s/^\s+//r; + + return q{:next} if $input eq q{}; # for the next page + + require Encode; + $input = Encode::decode_utf8($input); + + my ($args, $keywords) = parse_options($input); + + if ($opt{history}) { + my $str = join(' ', grep { /\w/ } @{$args}, @{$keywords}); + if ($str ne '' and $str !~ /^[0-9]{1,3}\z/) { + $term->append_history(1, $opt{history_file}); + } + } + + apply_input_arguments($args, $keywords); + return @{$keywords}; +} + +sub logout { + + unlink $authentication_file + or warn "Can't unlink: `$authentication_file' -> $!"; + + $yv_obj->set_access_token(); + $yv_obj->set_refresh_token(); + + return 1; +} + +sub authenticate { + my $get_code_url = $yv_obj->get_accounts_oauth_url() // return; + + print <<"INFO"; + +:: Get the authentication code: $get_code_url + + | +... and paste it below. \\|/ + ` +INFO + + my $code = $term->readline(colored(q{Code: }, 'bold')) || return; + + my $info = $yv_obj->oauth_login($code) // do { + warn "[WARNING] Can't log in... That's all I know...\n"; + return; + }; + + if (defined $info->{access_token}) { + + $yv_obj->set_access_token($info->{access_token}) // return; + $yv_obj->set_refresh_token($info->{refresh_token}) // return; + + my $remember_me = ask_yn(prompt => colored("\nRemember me", 'bold'), + default => 'y'); + + if ($remember_me) { + $yv_obj->set_authentication_file($authentication_file); + $yv_obj->save_authentication_tokens() + or warn "Can't store the authentication tokens: $!"; + } + else { + $yv_obj->set_authentication_file(); + } + + return 1; + } + + warn "[WARNING] There was a problem with the authentication...\n"; + return; +} + +sub authenticated { + if (not defined $yv_obj->get_access_token) { + warn_needs_auth(); + return; + } + return 1; +} + +sub favorite_videos { + my (@videoIDs) = @_; + return if not authenticated(); + + foreach my $id (@videoIDs) { + my $videoID = get_valid_video_id($id) // next; + + if ($yv_obj->favorite_video($videoID)) { + printf("\n:: Video %s has been successfully favorited.\n", sprintf($CONFIG{youtube_video_url}, $videoID)); + } + else { + warn_cant_do('favorite', $videoID); + } + } + return 1; +} + +sub select_and_save_to_playlist { + return if not authenticated(); + + my $request = $yv_obj->my_playlists() // return; + my $playlistID = print_playlists($request, return_playlist_id => 1); + + if (defined($playlistID)) { + return save_to_playlist($playlistID, @_); + } + + warn_no_thing_selected('playlist'); + return; + +} + +sub save_to_playlist { + my ($playlistID, @videoIDs) = @_; + + return if not authenticated(); + + foreach my $id (@videoIDs) { + my $videoID = get_valid_video_id($id) // next; + my $pos = $opt{position}; # position in the playlist + + if (!defined($pos) or $pos < 0) { + local $yv_obj->{maxResults} = 1; + + my $info = $yv_obj->videos_from_playlist_id($playlistID); + my $total_results = $info->{results}{pageInfo}{totalResults}; + + say "\n:: Total number of videos in the playlist: $total_results"; + $pos //= 0; + $pos += $total_results || 0; + say ":: Saving video at position: $pos"; + } + + if ($yv_obj->add_video_to_playlist($playlistID, $videoID, $pos)) { + printf(":: Video %s has been successfully added to playlistID: %s\n", + sprintf($CONFIG{youtube_video_url}, $videoID), $playlistID); + } + else { + warn_cant_do("add to playlist", $videoID); + } + } + return 1; +} + +sub rate_videos { + my $rating = shift; + return if not authenticated(); + + foreach my $id (@_) { + my $videoID = get_valid_video_id($id) // next; + if ($yv_obj->send_rating_to_video($videoID, $rating)) { + printf("\n:: Video %s has been successfully %sd.\n", sprintf($CONFIG{youtube_video_url}, $videoID), $rating); + } + else { + warn_cant_do($rating, $videoID); + } + } + + return 1; +} + +sub get_and_play_video_ids { + (my @ids = grep { defined($_) } map { get_valid_video_id($_) } @_) || return; + + foreach my $id (@ids) { + my $info = $yv_obj->video_details($id); + + if (ref($info) eq 'HASH' and keys %$info) { + ## OK + } + else { + $info->{title} = "unknown"; + $info->{lengthSeconds} = 0; + $info->{videoId} = $id; + warn_cant_do('get info for', $id); + } + + play_videos([$info]) || return; + } + + return 1; +} + +sub get_and_play_playlists { + foreach my $id (@_) { + my $videos = $yv_obj->videos_from_playlist_id(get_valid_playlist_id($id) // next); + local $opt{play_all} = length($opt{std_input}) ? 0 : 1; + print_videos($videos, auto => $opt{play_all}); + } + return 1; +} + +sub get_and_print_video_info { + foreach my $id (@_) { + + my $videoID = get_valid_video_id($id) // next; + my $info = $yv_obj->video_details($videoID); + + if (ref($info) eq 'HASH' and keys %$info) { + local $opt{show_video_info} = 1; + print_video_info($info); + } + else { + warn_cant_do('get info for', $videoID); + } + } + return 1; +} + +sub get_and_print_related_videos { + foreach my $id (@_) { + my $videoID = get_valid_video_id($id) // next; + my $results = $yv_obj->related_to_videoID($videoID); + print_videos($results); + } + return 1; +} + +sub get_and_print_comments { + foreach my $id (@_) { + my $videoID = get_valid_video_id($id) // next; + my $comments = $yv_obj->comments_from_video_id($videoID); + print_comments($comments, $videoID); + } + return 1; +} + +sub get_and_print_videos_from_playlist { + my ($playlistID) = @_; + + if ($playlistID =~ /$valid_playlist_id_re/) { + my $info = $yv_obj->videos_from_playlist_id($playlistID); + if ($yv_utils->has_entries($info)) { + print_videos($info); + } + else { + warn colored("\n[!] Inexistent playlist...", 'bold red') . "\n"; + return; + } + } + else { + warn_invalid('playlistID', $playlistID); + return; + } + return 1; +} + +sub subscribe { + my (@ids) = @_; + + return if not authenticated(); + + foreach my $channel (@ids) { + + my $id = extract_channel_id($channel) // do { + warn_invalid("channel ID or username", $channel); + next; + }; + + my $ok = + $yv_utils->is_channelID($id) + ? $yv_obj->subscribe_channel($id) + : $yv_obj->subscribe_channel_from_username($id); + + if ($ok) { + print ":: Successfully subscribed to channel: $id\n"; + } + else { + warn colored("\n[!] Unable to subscribe to channel: $id", 'bold red') . "\n"; + } + } + + return 1; +} + +sub _bold_color { + my ($text) = @_; + return colored($text, 'bold'); +} + +sub youtube_urls { + my ($arg) = @_; + + if ($arg =~ /$get_video_id_re/) { + get_and_play_video_ids($+{video_id}); + } + elsif ($arg =~ /$get_playlist_id_re/) { + get_and_print_videos_from_playlist($+{playlist_id}); + } + elsif ($arg =~ /$get_channel_playlists_id_re/) { + print_playlists($yv_obj->playlists($+{channel_id})); + } + elsif ($arg =~ /$get_channel_videos_id_re/) { + print_videos($yv_obj->uploads($+{channel_id})); + } + elsif ($arg =~ /$get_username_playlists_re/) { + print_playlists($yv_obj->playlists_from_username($+{username})); + } + elsif ($arg =~ /$get_username_videos_re/) { + print_videos($yv_obj->uploads_from_username($+{username})); + } + else { + return; + } + + return 1; +} + +sub general_options { + my %args = @_; + + my $url = $args{url}; + my $option = $args{opt}; + my $callback = $args{sub}; + my $results = $args{res}; + my $info = $args{info}; + + my $token = undef; + my $has_token = 0; + + if (ref($info->{results}) eq 'HASH' and exists $info->{results}{continuation}) { + $has_token = 1; + $token = $info->{results}{continuation}; + } + + if (not defined($option)) { + return; + } + + if ($option =~ /^(?:q|quit|exit)\z/) { + main_quit(0); + } + elsif ($option =~ /^(?:n|next)\z/ and defined $url) { + if ($has_token) { + if (defined $token) { + my $request = $yv_obj->next_page($url, $token); + $callback->($request); + } + else { + warn_last_page(); + } + } + else { + my $request = $yv_obj->next_page($url); + $callback->($request); + } + } + elsif ($option =~ /^(?:b|back|p|prev|previous)\z/ and defined $url) { + my $request = $yv_obj->previous_page($url); + $callback->($request); + } + elsif ($option =~ /^(?:R|refresh)\z/ and defined $url) { + @{$results} = @{$yv_obj->_get_results($url)->{results}}; + } + elsif ($option eq 'login') { + authenticate(); + } + elsif ($option eq 'logout') { + logout(); + } + elsif ($option =~ /^(?:reset|reload|restart)\z/) { + @ARGV = (); + do $0; + } + elsif ($option =~ /^dv${digit_or_equal_re}(.*)/ and ref($results) eq 'ARRAY') { + if (my @nums = get_valid_numbers($#{$results}, $1)) { + print "\n"; + foreach my $num (@nums) { + require Data::Dump; + say Data::Dump::pp($results->[$num]); + } + press_enter_to_continue(); + } + else { + warn_no_thing_selected('result'); + } + } + elsif ($option =~ /^v(?:ideoids?)?=(.*)/) { + if (my @ids = split(/[,\s]+/, $1)) { + get_and_play_video_ids(@ids); + } + else { + warn colored("\n[!] No video ID specified!", 'bold red') . "\n"; + } + } + elsif ($option =~ /^playlist(?:ID)?=(.*)/) { + get_and_print_videos_from_playlist($1); + } + else { + return; + } + + return 1; +} + +sub warn_no_results { + warn colored("\n[!] No $_[0] results!", 'bold red') . "\n"; +} + +sub warn_invalid { + my ($name, $option) = @_; + warn colored("\n[!] Invalid $name: <$option>", 'bold red') . "\n"; +} + +sub warn_cant_do { + my ($action, @ids) = @_; + + foreach my $videoID (@ids) { + warn colored("\n[!] Can't $action video: " . sprintf($CONFIG{youtube_video_url}, $videoID), 'bold red') . "\n"; + + my %info = $yv_obj->_get_video_info($videoID); + my $resp = $yv_obj->parse_json_string($info{player_response} // next); + + if (eval { exists($resp->{playabilityStatus}) and $resp->{playabilityStatus}{status} =~ /error/i }) { + warn colored("[+] Reason: $resp->{playabilityStatus}{reason}.", 'bold yellow') . "\n"; + } + } +} + +sub warn_last_page { + warn colored("\n[!] This is the last page!", "bold red") . "\n"; +} + +sub warn_first_page { + warn colored("\n[!] No previous page available...", 'bold red') . "\n"; +} + +sub warn_no_thing_selected { + warn colored("\n[!] No $_[0] selected!", 'bold red') . "\n"; +} + +sub warn_needs_auth { + warn colored("\n[!] This functionality needs authentication!", 'bold red') . "\n"; +} + +# ... GET INPUT SUBS ... # +sub get_input_for_first_time { + return get_user_input(_bold_color("\n=>> Search for YouTube videos (:h for help)") . "\n> "); +} + +sub get_input_for_channels { + return get_user_input(_bold_color("\n=>> Select a channel (:h for help)") . "\n> "); +} + +sub get_input_for_search { + return get_user_input(_bold_color("\n=>> Select one or more videos to play (:h for help)") . "\n> "); +} + +sub get_input_for_playlists { + return get_user_input(_bold_color("\n=>> Select a playlist (:h for help)") . "\n> "); +} + +sub get_input_for_comments { + return get_user_input(_bold_color("\n=>> Press for the next page of comments (:h for help)") . "\n> "); +} + +sub get_input_for_categories { + return get_user_input(_bold_color("\n=>> Select a category (:h for help)") . "\n> "); +} + +sub ask_yn { + my (%opt) = @_; + my $c = join('/', map { $_ eq $opt{default} ? ucfirst($_) : $_ } qw(y n)); + + my $answ; + do { + $answ = lc($term->readline($opt{prompt} . " [$c]: ")); + $answ = $opt{default} unless $answ =~ /\S/; + } while ($answ !~ /^y(?:es)?$/ and $answ !~ /^no?$/); + + return chr(ord($answ)) eq 'y'; +} + +sub get_reply { + my (%opt) = @_; + + my $default = 1; + while (my ($i, $choice) = each @{$opt{choices}}) { + print "\n" if $i == 0; + printf("%3d> %s\n", $i + 1, $choice); + if ($choice eq $opt{default}) { + $default = $i + 1; + } + } + print "\n"; + + my $answ; + do { + $answ = $term->readline($opt{prompt} . " [$default]: "); + $answ = $default unless $answ =~ /\S/; + } while ($answ !~ /^[0-9]+\z/ or $answ < 1 or $answ > @{$opt{choices}}); + + return $opt{choices}[$answ - 1]; +} + +sub valid_num { + my ($num, $array_ref) = @_; + return $num =~ /^[0-9]{1,3}\z/ && $num != 0 && $num <= @{$array_ref}; +} + +sub adjust_width { + my ($str, $len, $prepend) = @_; + + if ($len <= 0) { + return $str; + } + + state $pkg = ( + eval { + require Unicode::GCString; + 'Unicode::GCString'; + } // eval { + require Text::CharWidth; + 'Text::CharWidth'; + } // do { + warn "[WARN] Please install Unicode::GCString or Text::CharWidth in order to use this functionality.\n"; + ''; + } + ); + + # + ## Unicode::GCString + # + if ($pkg eq 'Unicode::GCString') { + + my $gcstr = Unicode::GCString->new($str); + my $str_width = $gcstr->columns; + + if ($str_width != $len) { + while ($str_width > $len) { + $gcstr = $gcstr->substr(0, -1); + $str_width = $gcstr->columns; + } + + $str = $gcstr->as_string; + my $spaces = ' ' x ($len - $str_width); + $str = $prepend ? "$spaces$str" : "$str$spaces"; + } + + return $str; + } + + # + ## Text::CharWidth + # + if ($pkg eq 'Text::CharWidth') { + + my $str_width = Text::CharWidth::mbswidth($str); + + if ($str_width != $len) { + while ($str_width > $len) { + chop $str; + $str_width = Text::CharWidth::mbswidth($str); + } + + my $spaces = ' ' x ($len - $str_width); + $str = $prepend ? "$spaces$str" : "$str$spaces"; + } + + return $str; + } + + return $str; +} + +# ... PRINT SUBROUTINES ... # +sub print_channels { + my ($results) = @_; + + if (not $yv_utils->has_entries($results)) { + warn_no_results("channel"); + } + + if ($opt{get_term_width} and $opt{results_fixed_width}) { + get_term_width(); + } + + my $url = $results->{url}; + my $channels = $results->{results} // []; + + foreach my $i (0 .. $#{$channels}) { + my $channel = $channels->[$i]; + + if ($opt{results_with_details}) { + printf( + "\n%s. %s\n %s: %-23s %s: %-12s\n%s\n", + colored(sprintf('%2d', $i + 1), 'bold') => colored($yv_utils->get_channel_title($channel), 'bold blue'), + colored('Updated' => 'bold') => $yv_utils->get_publication_date($channel), + colored('Author' => 'bold') => $yv_utils->get_channel_title($channel), + wrap_text( + i_tab => q{ } x 4, + s_tab => q{ } x 4, + text => [$yv_utils->get_description($channel) || 'No description available...'] + ), + ); + } + elsif ($opt{results_with_colors}) { + print "\n" if $i == 0; + printf("%s. %s (%s)\n", + colored(sprintf('%2d', $i + 1), 'bold'), + colored($yv_utils->get_channel_title($channel), 'blue'), + colored($yv_utils->get_publication_date($channel), 'magenta'), + ); + } + elsif ($opt{results_fixed_width}) { + + require List::Util; + + my @authors = map { $yv_utils->get_channel_id($_) } @{$channels}; + my @dates = map { $yv_utils->get_publication_date($_) } @{$channels}; + + my $author_width = List::Util::min(List::Util::max(map { length($_) } @authors), int($term_width / 5)); + my $dates_width = List::Util::max(map { length($_) } @dates); + my $title_length = $term_width - ($author_width + $dates_width + 2 + 3 + 1 + 2); + + print "\n"; + foreach my $i (0 .. $#{$channels}) { + + my $channel = $channels->[$i]; + my $title = clear_title($yv_utils->get_channel_title($channel)); + + printf "%s. %s %s [%*s]\n", colored(sprintf('%2d', $i + 1), 'bold'), + adjust_width($title, $title_length), + adjust_width($authors[$i], $author_width, 1), + $dates_width, $dates[$i]; + } + last; + } + else { + print "\n" if $i == 0; + printf "%s. %s [%s]\n", colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_channel_title($channel), + $yv_utils->get_publication_date($channel); + } + } + + my @keywords = get_input_for_channels(); + + my @for_search; + foreach my $key (@keywords) { + if ($key =~ /$valid_opt_re/) { + + my $opt = $1; + + if ( + general_options( + opt => $opt, + sub => __SUB__, + url => $url, + res => $channels, + info => $results, + ) + ) { + ## ok + } + elsif ($opt =~ /^(?:h|help)\z/) { + print $general_help; + press_enter_to_continue(); + } + elsif ($opt =~ /^(?:r|return)\z/) { + return; + } + else { + warn_invalid('option', $opt); + } + } + elsif (youtube_urls($key)) { + ## ok + } + elsif (valid_num($key, $channels)) { + print_videos($yv_obj->uploads($yv_utils->get_channel_id($channels->[$key - 1]))); + } + else { + push @for_search, $key; + } + } + + if (@for_search) { + __SUB__->($yv_obj->search_channels(\@for_search)); + } + + __SUB__->(@_); +} + +sub print_comments { + my ($results, $videoID) = @_; + + if (not $yv_utils->has_entries($results)) { + warn_no_results("comments"); + } + + my $url = $results->{url}; + my $comments = $results->{results}{comments} // []; + + my $i = 0; + foreach my $comment (@{$comments}) { + my $comment_id = $yv_utils->get_comment_id($comment); + my $comment_age = $yv_utils->get_publication_age_approx($comment); + + printf( + "\n%s (%s) commented:\n%s\n", + colored($yv_utils->get_author($comment), 'bold'), + ( + $comment_age =~ /sec|min|hour|day/ + ? "$comment_age ago" + : $yv_utils->get_publication_date($comment) + ), + wrap_text( + i_tab => q{ } x 3, + s_tab => q{ } x 3, + text => [$yv_utils->get_comment_content($comment) // 'Empty comment...'] + ), + ); + + #~ if (exists $comment->{replies}) { + #~ foreach my $reply (reverse @{$comment->{replies}{comments}}) { + #~ my $reply_age = $yv_utils->date_to_age($reply->{snippet}{publishedAt}); + #~ printf( + #~ "\n %s (%s) replied:\n%s\n", + #~ colored($reply->{snippet}{authorDisplayName}, 'bold'), + #~ ( + #~ $reply_age =~ /sec|min|hour|day/ + #~ ? "$reply_age ago" + #~ : $yv_utils->format_date($reply->{snippet}{publishedAt}) + #~ ), + #~ wrap_text( + #~ i_tab => q{ } x 6, + #~ s_tab => q{ } x 6, + #~ text => [$reply->{snippet}{textDisplay} // 'Empty comment...'] + #~ ), + #~ ); + #~ } + #~ } + } + + my @keywords = get_input_for_comments(); + + foreach my $key (@keywords) { + if ($key =~ /$valid_opt_re/) { + + my $opt = $1; + + if ( + general_options( + opt => $opt, + sub => __SUB__, + url => $url, + res => $comments, + info => $results, + mode => 'comments', + args => [$videoID], + ) + ) { + ## ok + } + elsif ($opt =~ /^(?:h|help)\z/) { + print $comments_help; + press_enter_to_continue(); + } + elsif ($opt =~ /^(?:c|comment)\z/) { + if (authenticated()) { + require File::Temp; + my ($fh, $filename) = File::Temp::tempfile(); + $yv_obj->proxy_system($ENV{EDITOR} // 'nano', $filename); + if ($?) { + warn colored("\n[!] Editor exited with a non-zero code. Unable to continue!", 'bold red') . "\n"; + } + else { + my $comment = do { local (@ARGV, $/) = $filename; <> }; + $comment =~ s/[^\s[:^cntrl:]]+//g; # remove control characters + + if (length($comment) and $yv_obj->comment_to_video_id($comment, $videoID)) { + print "\n:: Comment posted!\n"; + } + else { + warn colored("\n[!] Your comment has NOT been posted!", 'bold red') . "\n"; + } + } + } + } + elsif ($opt =~ /^(?:r|return)\z/) { + return; + } + else { + warn_invalid('option', $opt); + } + } + elsif (youtube_urls($key)) { + ## ok + } + elsif (valid_num($key, $comments)) { + print_videos($yv_obj->get_videos_from_username($comments->[$key - 1]{author})); + } + else { + warn_invalid('keyword', $key); + } + } + + __SUB__->(@_); +} + +sub print_categories { + my ($results) = @_; + + my $categories = $results; + return if ref($categories) ne 'ARRAY'; + + my $i = 0; + print "\n" if @{$categories}; + + foreach my $category (@{$categories}) { + printf "%s. %-40s\n", colored(sprintf('%2d', ++$i), 'bold'), $category->{title}; + } + + my @keywords = get_input_for_categories(); + + foreach my $key (@keywords) { + if ($key =~ /$valid_opt_re/) { + + my $opt = $1; + + if ( + general_options( + opt => $opt, + sub => __SUB__, + res => $results, + ) + ) { + ## ok + } + elsif ($opt =~ /^(?:h|help)\z/) { + print $general_help; + press_enter_to_continue(); + } + elsif ($opt =~ /^(?:r|return)\z/) { + return; + } + else { + warn_invalid('option', $opt); + } + } + elsif (youtube_urls($key)) { + ## ok + } + elsif (valid_num($key, $categories)) { + my $category = $categories->[$key - 1]; + my $cat_id = $category->{id}; + my $videos = $yv_obj->trending_videos_from_category($cat_id); + + print_videos($videos); + } + else { + warn_invalid('keyword', $key); + } + } + + __SUB__->(@_); +} + +sub print_playlists { + my ($results, %args) = @_; + + if (not $yv_utils->has_entries($results)) { + warn_no_results("playlist"); + } + + if ($opt{get_term_width} and $opt{results_fixed_width}) { + get_term_width(); + } + + my $url = $results->{url}; + my $playlists = $results->{results} // []; + + if (ref($playlists) eq 'HASH') { + if (exists $playlists->{playlists}) { + $playlists = $playlists->{playlists}; + } + 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 + + foreach my $i (0 .. $#{$playlists}) { + my $playlist = $playlists->[$i]; + if ($opt{results_with_details}) { + printf( + "\n%s. %s\n %s: %-25s %s: %s\n%s\n", + colored(sprintf('%2d', $i + 1), 'bold') => colored($yv_utils->get_title($playlist), 'bold blue'), + colored('Updated' => 'bold') => $yv_utils->get_publication_date($playlist), + colored('Author' => 'bold') => $yv_utils->get_channel_title($playlist), + wrap_text( + i_tab => q{ } x 4, + s_tab => q{ } x 4, + text => [$yv_utils->get_description($playlist) || 'No description available...'] + ), + ); + } + elsif ($opt{results_with_colors}) { + print "\n" if $i == 0; + printf( + "%s. %s (%s) %s\n", + colored(sprintf('%2d', $i + 1), 'bold'), + colored($yv_utils->get_title($playlist), 'blue'), + colored($yv_utils->get_publication_date($playlist), 'magenta'), + colored($yv_utils->get_channel_title($playlist), 'green'), + ); + } + elsif ($opt{results_fixed_width}) { + + require List::Util; + + my @authors = map { $yv_utils->get_channel_title($_) } @{$playlists}; + my @dates = map { $yv_utils->get_publication_date($_) } @{$playlists}; + + my $author_width = List::Util::min(List::Util::max(map { length($_) } @authors), int($term_width / 5)); + my $dates_width = List::Util::max(map { length($_) } @dates); + my $title_length = $term_width - ($author_width + $dates_width + 2 + 3 + 1 + 2); + + print "\n"; + foreach my $i (0 .. $#{$playlists}) { + + my $playlist = $playlists->[$i]; + my $title = clear_title($yv_utils->get_title($playlist)); + + printf "%s. %s %s [%*s]\n", colored(sprintf('%2d', $i + 1), 'bold'), + adjust_width($title, $title_length), + adjust_width($authors[$i], $author_width, 1), + $dates_width, $dates[$i]; + } + last; + } + else { + print "\n" if $i == 0; + printf( + "%s. %s (by %s) [%s]\n", + colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_title($playlist), + $yv_utils->get_channel_title($playlist), $yv_utils->get_publication_date($playlist) + ); + } + } + + state @keywords; + if ($args{auto}) { } # do nothing... + else { + @keywords = get_input_for_playlists(); + if (scalar(@keywords) == 0) { + __SUB__->(@_); + } + } + + my $contains_keywords = grep /$non_digit_or_opt_re/, @keywords; + + my @for_search; + foreach my $key (@keywords) { + if ($key =~ /$valid_opt_re/) { + + my $opt = $1; + + if ( + general_options( + opt => $opt, + sub => __SUB__, + url => $url, + res => $playlists, + info => $results, + mode => 'playlists', + ) + ) { + ## ok + } + elsif ($opt =~ /^(?:h|help)\z/) { + print $playlists_help; + press_enter_to_continue(); + } + elsif ($opt =~ /^(?:r|return)\z/) { + return; + } + elsif ($opt =~ /^i(?:nfo)?${digit_or_equal_re}(.*)/) { + if (my @ids = get_valid_numbers($#{$playlists}, $1)) { + foreach my $id (@ids) { + my $desc = wrap_text( + i_tab => q{ } x 7, + s_tab => q{ } x 7, + text => [$yv_utils->get_description($playlists->[$id]) || 'No description available...'] + ); + $desc =~ s/^\s+//; + printf $info_format, $yv_utils->get_title($playlists->[$id]), + ($yv_utils->get_playlist_id($playlists->[$id])) x 2, $desc; + } + press_enter_to_continue(); + } + else { + warn_no_thing_selected('playlist'); + } + } + elsif ($opt =~ /^pp${digit_or_equal_re}(.*)/) { + if (my @ids = get_valid_numbers($#{$playlists}, $1)) { + my $arg = "--pp=" . join(q{,}, map { $yv_utils->get_playlist_id($_) } @{$playlists}[@ids]); + apply_input_arguments([$arg]); + } + else { + warn_no_thing_selected('playlist'); + } + } + else { + warn_invalid('option', $opt); + } + } + elsif (youtube_urls($key)) { + ## ok + } + elsif (valid_num($key, $playlists) and not $contains_keywords) { + + my $id = $yv_utils->get_playlist_id($playlists->[$key - 1]); + + if ($args{return_playlist_id}) { + return $id; + } + + get_and_print_videos_from_playlist($id); + } + else { + push @for_search, $key; + } + } + + if (@for_search) { + __SUB__->($yv_obj->search_playlists(\@for_search)); + } + + __SUB__->(@_); +} + +sub compile_regex { + my ($value) = @_; + $value =~ s{^(?['"])(?.+)\g{quote}$}{$+{regex}}s; + + my $re = eval { use re qw(eval); qr/$value/i }; + + if ($@) { + warn_invalid("regex", $@); + return; + } + + return $re; +} + +sub get_range_numbers { + my ($first, $second) = @_; + + return ( + $first > $second + ? (reverse($second .. $first)) + : ($first .. $second) + ); +} + +sub get_valid_numbers { + my ($max, $input) = @_; + + my @output; + foreach my $id (split(/[,\s]+/, $input)) { + push @output, + $id =~ /$range_num_re/ ? get_range_numbers($1, $2) + : $id =~ /^[0-9]{1,3}\z/ ? $id + : next; + } + + return grep { $_ >= 0 and $_ <= $max } map { $_ - 1 } @output; +} + +sub get_streaming_url { + my ($video_id) = @_; + + my ($urls, $captions, $info) = $yv_obj->get_streaming_urls($video_id); + + if (not defined $urls) { + return scalar {}; + } + + # Download the closed-captions + my $srt_file; + if (ref($captions) eq 'ARRAY' and @$captions and $opt{get_captions} and not $opt{novideo}) { + require WWW::PipeViewer::GetCaption; + my $yv_cap = WWW::PipeViewer::GetCaption->new( + auto_captions => $opt{auto_captions}, + captions_dir => $opt{cache_dir}, + captions => $captions, + languages => $CONFIG{srt_languages}, + yv_obj => $yv_obj, + ); + $srt_file = $yv_cap->save_caption($video_id); + } + + require WWW::PipeViewer::Itags; + state $yv_itags = WWW::PipeViewer::Itags->new(); + + # Include DASH itags + my $dash = 1; + + # Exclude DASH itags in download-mode or when no video output is required + if ($opt{novideo} or not $opt{dash_support}) { + $dash = 0; + } + elsif ($opt{download_video}) { + $dash = $opt{merge_into_mkv} ? 1 : 0; + } + + my ($streaming, $resolution) = $yv_itags->find_streaming_url( + urls => $urls, + resolution => ($opt{novideo} ? 'audio' : $opt{resolution}), + + 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}), + ); + + return { + streaming => $streaming, + srt_file => $srt_file, + info => $info, + resolution => $resolution, + }; +} + +sub download_from_url { + my ($url, $output_filename) = @_; + + # Download with wget + if ($opt{download_with_wget}) { + my @cmd = ($opt{wget_cmd}, '-c', '-t', '10', '--waitretry=3', $url, '-O', "$output_filename.part"); + $yv_obj->proxy_system(@cmd); + return if $?; + rename("$output_filename.part", $output_filename) or return undef; + return $output_filename; + } + + state $lwp_dl = which_command('lwp-download'); + + # Download with lwp-download + if (defined($lwp_dl)) { + my @cmd = ($lwp_dl, $url, "$output_filename.part"); + $yv_obj->proxy_system(@cmd); + return if $?; + rename("$output_filename.part", $output_filename) or return undef; + return $output_filename; + } + + # Download with LWP::UserAgent + require LWP::UserAgent; + + my $lwp = LWP::UserAgent->new( + show_progress => 1, + agent => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36', + ); + + $lwp->proxy(['http', 'https'], $yv_obj->get_http_proxy) + if defined($yv_obj->get_http_proxy); + + my $resp = eval { $lwp->mirror($url, "$output_filename.part") }; + + if ($@ =~ /\bread timeout\b/i or not defined($resp) or not $resp->is_success) { + warn colored("\n[!] Encountered an error while downloading... Trying again...", 'bold red') . "\n\n"; + + if (defined(my $wget_path = which_command('wget'))) { + $CONFIG{wget_cmd} = $wget_path; + $CONFIG{download_with_wget} = 1; + dump_configuration($config_file); + } + else { + warn colored("[!] Please install `wget` and try again...", 'bold red') . "\n\n"; + } + + unlink("$output_filename.part"); + return download_from_url($url, $output_filename); + } + + rename("$output_filename.part", $output_filename) or return undef; + return $output_filename; +} + +sub download_video { + my ($streaming, $info) = @_; + + my $fat32safe = $opt{fat32safe}; + state $unix_like = $^O =~ /^(?:linux|freebsd|openbsd)\z/i; + + if (not $fat32safe and not $unix_like) { + $fat32safe = 1; + } + + my $video_filename = $yv_utils->format_text( + streaming => $streaming, + info => $info, + text => $opt{video_filename_format}, + escape => 0, + fat32safe => $fat32safe, + ); + + my $naked_filename = $video_filename =~ s/\.\w+\z//r; + + $naked_filename =~ s/\h*:+\h*/ - /g; # replace colons (":") with dashes ("-") + + my $mkv_filename = "$naked_filename.mkv"; + my $srt_filename = "$naked_filename.srt"; + my $audio_filename = "$naked_filename - audio"; + + my $video_info = $streaming->{streaming}; + my $audio_info = $streaming->{streaming}{__AUDIO__}; + + if ($audio_info) { + $audio_filename .= "." . $yv_utils->extension($audio_info->{type}); + } + + if (not -d $opt{downloads_dir}) { + require File::Path; + if (not File::Path::make_path($opt{downloads_dir})) { + warn colored("\n[!] Can't create directory '$opt{downloads_dir}': $1", 'bold red') . "\n"; + } + } + + if (not -w $opt{downloads_dir}) { + warn colored("\n[!] Can't write into directory '$opt{downloads_dir}': $!", 'bold red') . "\n"; + $opt{downloads_dir} = (-w curdir()) ? curdir() : (-w $ENV{HOME}) ? $ENV{HOME} : return; + warn colored("[!] Video will be downloaded into directory: $opt{downloads_dir}", 'bold red') . "\n"; + } + + $mkv_filename = catfile($opt{downloads_dir}, $mkv_filename); + $srt_filename = catfile($opt{downloads_dir}, $srt_filename); + $audio_filename = catfile($opt{downloads_dir}, $audio_filename); + $video_filename = catfile($opt{downloads_dir}, $video_filename); + + if ($opt{skip_if_exists} and -e $mkv_filename) { + $video_filename = $mkv_filename; + say ":: File `$mkv_filename` already exists. Skipping..."; + } + else { + if ($opt{skip_if_exists} and -e $video_filename) { + say ":: File `$video_filename` already exists. Skipping..."; + } + else { + $video_filename = download_from_url($video_info->{url}, $video_filename) // return; + } + + if ($opt{skip_if_exists} and -e $audio_filename) { + say ":: File `$audio_filename` already exists. Skipping..."; + } + elsif ($audio_info) { + $audio_filename = download_from_url($audio_info->{url}, $audio_filename) // return; + } + } + + my @merge_files = ($video_filename); + + if ($audio_info) { + push @merge_files, $audio_filename; + } + + if ( $opt{merge_with_captions} + and defined($streaming->{srt_file}) + and -f $streaming->{srt_file}) { + push @merge_files, $streaming->{srt_file}; + } + + if ( $opt{merge_into_mkv} + and scalar(@merge_files) > 1 + and scalar(grep { -f $_ } @merge_files) == scalar(@merge_files) + and not -e $mkv_filename) { + + say ":: Merging into MKV..."; + + my $ffmpeg_cmd = $opt{ffmpeg_cmd}; + my $ffmpeg_args = $opt{merge_into_mkv_args}; + + if (my @srt_files = grep { /\.srt\z/ } @merge_files) { + my $srt_file = $srt_files[0]; + require File::Basename; + if (File::Basename::basename($srt_file) =~ m{^.{11}_([a-z]{2,4})}i) { + my $lang_code = $1; + $ffmpeg_args .= " -metadata:s:s:0 language=$lang_code"; + } + } + + my $merge_command = + join(' ', $ffmpeg_cmd, (map { "-i \Q$_\E" } @merge_files), $ffmpeg_args, "\Q$mkv_filename\E"); + + if ($yv_obj->get_debug) { + say "-> Command: $merge_command"; + } + + $yv_obj->proxy_system($merge_command); + + if ($? == 0 and -e $mkv_filename) { + unlink @merge_files; + $video_filename = $mkv_filename; + } + } + + # Convert the downloaded video + if (defined $opt{convert_to}) { + my $convert_filename = catfile($opt{downloads_dir}, "$naked_filename.$opt{convert_to}"); + my $convert_cmd = $opt{convert_cmd}; + + my %table = ( + 'IN' => $video_filename, + 'OUT' => $convert_filename, + ); + + my $regex = do { + local $" = '|'; + qr/\*(@{[keys %table]})\*/; + }; + + $convert_cmd =~ s/$regex/\Q$table{$1}\E/g; + say $convert_cmd if $yv_obj->get_debug; + + $yv_obj->proxy_system($convert_cmd); + + if ($? == 0) { + + if (not $opt{keep_original_video}) { + unlink $video_filename + or warn colored("\n[!] Can't unlink file '$video_filename': $!", 'bold red') . "\n\n"; + } + + $video_filename = $convert_filename if -e $convert_filename; + } + } + + # Play the download video + if ($opt{download_and_play}) { + + local $streaming->{streaming}{url} = ''; + local $streaming->{streaming}{__AUDIO__} = undef; + local $streaming->{srt_file} = undef if ($opt{merge_into_mkv} && $opt{merge_with_captions}); + + my $command = get_player_command($streaming, $info); + say "-> Command: ", $command if $yv_obj->get_debug; + + $yv_obj->proxy_system(join(q{ }, $command, quotemeta($video_filename))); + + # Remove it afterwards + if ($? == 0 and $opt{remove_played_file}) { + unlink $video_filename + or warn colored("\n[!] Can't unlink file '$video_filename': $!", 'bold red') . "\n\n"; + } + } + + # Copy the .srt file to downloads-dir + if ( $opt{copy_caption} + and -e $video_filename + and defined($streaming->{srt_file}) + and -e $streaming->{srt_file}) { + + my $from = $streaming->{srt_file}; + my $to = $srt_filename; + + require File::Copy; + File::Copy::cp($from, $to); + } + + return 1; +} + +sub save_watched_video { + my ($video_id) = @_; + + if ($opt{remember_watched} and not exists($watched_videos{$video_id})) { + $watched_videos{$video_id} = 1; + open my $fh, '>>', $opt{watched_file} or return; + say {$fh} $video_id; + close $fh; + } + + $watched_videos{$video_id} = 1; + return 1; +} + +sub get_player_command { + my ($streaming, $video) = @_; + + $MPLAYER{fullscreen} = $opt{fullscreen} ? $opt{video_players}{$opt{video_player_selected}}{fs} // '' : q{}; + $MPLAYER{novideo} = $opt{novideo} ? $opt{video_players}{$opt{video_player_selected}}{novideo} // '' : q{}; + $MPLAYER{arguments} = $opt{video_players}{$opt{video_player_selected}}{arg} // q{}; + + my $cmd = join( + q{ }, + ( + # Video player + $opt{video_players}{$opt{video_player_selected}}{cmd}, + + ( # Audio file (https://) + ref($streaming->{streaming}{__AUDIO__}) eq 'HASH' + && exists($opt{video_players}{$opt{video_player_selected}}{audio}) + ? $opt{video_players}{$opt{video_player_selected}}{audio} + : () + ), + + ( # Subtitle file (.srt) + defined($streaming->{srt_file}) + && exists($opt{video_players}{$opt{video_player_selected}}{srt}) + ? $opt{video_players}{$opt{video_player_selected}}{srt} + : () + ), + + # Rest of the arguments + grep({ defined($_) and /\S/ } values %MPLAYER) + ) + ); + + my $has_video = $cmd =~ /\*(?:VIDEO|URL|ID)\*/; + + $cmd = $yv_utils->format_text( + streaming => $streaming, + info => $video, + text => $cmd, + escape => 1, + ); + + if ($streaming->{streaming}{url} =~ m{^https://www\.youtube\.com/watch\?v=}) { + $cmd =~ s{\s*--no-ytdl\b}{ }g; + } + + $has_video ? $cmd : join(' ', $cmd, quotemeta($streaming->{streaming}{url})); +} + +sub autoplay { + my $video_id = get_valid_video_id(shift) // return; + + my %seen = ($video_id => 1); # make sure we don't get stuck in a loop + local $yv_obj->{maxResults} = 10; + + while (1) { + get_and_play_video_ids($video_id) || return; + my $related = $yv_obj->related_to_videoID($video_id); + (my @video_ids = grep { !$seen{$_}++ } map { $yv_utils->get_video_id($_) } @{$related->{results}}) || return; + $video_id = $opt{shuffle} ? $video_ids[rand @video_ids] : $video_ids[0]; + } + + return 1; +} + +sub play_videos { + my ($videos) = @_; + + foreach my $video (@{$videos}) { + + my $video_id = $yv_utils->get_video_id($video); + + if ($opt{autoplay_mode}) { + local $opt{autoplay_mode} = 0; + autoplay($video_id); + next; + } + + # Ignore already watched videos + if (exists($watched_videos{$video_id}) and $opt{skip_watched}) { + say ":: Already watched video (ID: $video_id)... Skipping..."; + next; + } + + if (defined($opt{max_seconds}) and $opt{max_seconds} >= 0) { + next if $yv_utils->get_duration($video) > $opt{max_seconds}; + } + + if (defined($opt{min_seconds}) and $opt{min_seconds} >= 0) { + next if $yv_utils->get_duration($video) < $opt{min_seconds}; + } + + my $streaming = get_streaming_url($video_id); + + if (ref($streaming->{streaming}) ne 'HASH') { + warn colored("[!] No streaming URL has been found...", 'bold red') . "\n"; + next; + } + + if ( !defined($streaming->{streaming}{url}) + and defined($streaming->{info}{status}) + and $streaming->{info}{status} =~ /(?:error|fail)/i) { + warn colored("[!] Error on: ", 'bold red') . sprintf($CONFIG{youtube_video_url}, $video_id) . "\n"; + warn colored(":: Reason: ", 'bold red') . $streaming->{info}{reason} =~ tr/+/ /r . "\n\n"; + } + + # Dump metadata information + if (defined($opt{dump})) { + + my $file = $video_id . '.' . $opt{dump}; + open(my $fh, '>:utf8', $file) + or die "Can't open file `$file' for writing: $!"; + + local $video->{streaming} = $streaming; + + if ($opt{dump} eq 'json') { + print {$fh} JSON->new->pretty(1)->encode($video); + } + elsif ($opt{dump} eq 'perl') { + require Data::Dump; + print {$fh} Data::Dump::pp($video); + } + + close $fh; + } + + if ($opt{download_video}) { + print_video_info($video); + if (not download_video($streaming, $video)) { + return; + } + } + elsif (length($opt{extract_info})) { + my $fh = $opt{extract_info_fh} // \*STDOUT; + say {$fh} + $yv_utils->format_text( + streaming => $streaming, + info => $video, + text => $opt{extract_info}, + escape => $opt{escape_info}, + fat32safe => $opt{fat32safe}, + ); + } + else { + print_video_info($video); + my $command = get_player_command($streaming, $video); + + if ($yv_obj->get_debug) { + say "-> Resolution: $streaming->{resolution}"; + say "-> Video itag: $streaming->{streaming}{itag}"; + say "-> Audio itag: $streaming->{streaming}{__AUDIO__}{itag}" if exists $streaming->{streaming}{__AUDIO__}; + say "-> Video type: $streaming->{streaming}{type}"; + say "-> Audio type: $streaming->{streaming}{__AUDIO__}{type}" if exists $streaming->{streaming}{__AUDIO__}; + say "-> Command: $command"; + } + + $yv_obj->proxy_system($command); # execute the video player + + if ($? and $? != 512) { + $opt{auto_next_page} = 0; + return; + } + } + + save_watched_video($video_id); + press_enter_to_continue() if $opt{confirm}; + } + + return 1; +} + +sub play_videos_matched_by_regex { + my %args = @_; + + my $key = $args{key}; + my $regex = $args{regex}; + my $videos = $args{videos}; + + my $sub = \&{'WWW::PipeViewer::Utils' . '::' . 'get_' . $key}; + + if (not defined &$sub) { + warn colored("\n[!] Invalid key: <$key>.", 'bold red') . "\n"; + return; + } + + if (defined(my $re = compile_regex($regex))) { + if (my @nums = grep { $yv_utils->$sub($videos->[$_]) =~ /$re/ } 0 .. $#{$videos}) { + if (not play_videos([@{$videos}[@nums]])) { + return; + } + } + else { + warn colored("\n[!] No video <$key> matched by the regex: $re", 'bold red') . "\n"; + return; + } + } + + return 1; +} + +sub print_video_info { + my ($video) = @_; + + $opt{show_video_info} || return 1; + + my $hr = '-' x ($opt{get_term_width} ? get_term_width() : $term_width); + + printf( + "\n%s\n%s\n%s\n%s\n%s", + _bold_color('=> Description'), + $hr, + wrap_text( + i_tab => q{}, + s_tab => q{}, + text => [$yv_utils->get_description($video) || 'No description available...'] + ), + $hr, + _bold_color('=> URL: ') + ); + + print STDOUT sprintf($CONFIG{youtube_video_url}, $yv_utils->get_video_id($video)); + + my $title = $yv_utils->get_title($video); + my $title_length = length($title); + my $rep = ($term_width - $title_length) / 2 - 4; + + $rep = 0 if $rep < 0; + + print( + "\n$hr\n", + q{ } x $rep => (_bold_color("=>> $title <<=") . "\n\n"), + ( + map { sprintf(q{-> } . "%-*s: %s\n", $opt{_colors} ? 18 : 10, _bold_color($_->[0]), $_->[1]) } + grep { defined($_->[1]) and $_->[1] !~ /^(0|unknown)\z/i } ( + ['Channel' => $yv_utils->get_channel_title($video)], + ['ChannelID' => $yv_utils->get_channel_id($video)], + ['VideoID' => $yv_utils->get_video_id($video)], + ['Category' => $yv_utils->get_category_name($video)], + ['Definition' => $yv_utils->get_definition($video)], + ['Duration' => $yv_utils->get_time($video)], + ['Likes' => $yv_utils->set_thousands($yv_utils->get_likes($video))], + ['Dislikes' => $yv_utils->set_thousands($yv_utils->get_dislikes($video))], + ['Views' => $yv_utils->set_thousands($yv_utils->get_views($video))], + ['Published' => $yv_utils->get_publication_date($video)], + ) + ), + "$hr\n" + ); + + return 1; +} + +sub print_videos { + my ($results, %args) = @_; + + if (not $yv_utils->has_entries($results)) { + warn_no_results("video"); + } + + if ($opt{get_term_width} and $opt{results_fixed_width}) { + get_term_width(); + } + + my $url = $results->{url}; + my $videos = $results->{results} // []; + + if (ref($videos) eq 'HASH' and exists $videos->{videos}) { + $videos = $videos->{videos}; + } + + if (ref($videos) ne 'ARRAY') { + + my $current_instance = $yv_obj->get_api_host(); + + say "\n:: Probably $current_instance is down. Try:"; + say "\n\t$0 --api=auto\n"; + say "See also: https://github.com/trizen/pipe-viewer#invidious-instances"; + return; + } + + #my $videos = $info->{items} // []; + + #~ foreach my $entry (@$videos) { + #~ if ($yv_utils->is_activity($entry)) { + #~ my $type = $entry->{snippet}{type}; + + #~ if ($type eq 'upload') { + #~ $entry->{kind} = 'youtube#video'; + #~ $entry->{id} = $entry->{contentDetails}{upload}{videoId}; + #~ } + + #~ if ($type eq 'playlistItem') { + #~ $entry->{kind} = 'youtube#video'; + #~ $entry->{id} = $entry->{contentDetails}{playlistItem}{resourceId}{videoId}; + #~ } + + #~ if ($type eq 'bulletin' and $entry->{contentDetails}{bulletin}{resourceId}{kind} eq 'youtube#video') { + #~ $entry->{kind} = 'youtube#video'; + #~ $entry->{id} = $entry->{contentDetails}{bulletin}{resourceId}{videoId}; + #~ } + #~ } + #~ } + +#<<< + #~ @$videos = grep { + #~ ref($_) eq 'HASH' && ref($_->{id}) eq 'HASH' + #~ ? (exists($_->{id}{kind}) + #~ ? $_->{id}{kind} eq 'youtube#video' + #~ : 0) + #~ : 1 + #~ } @$videos; +#>>> + + if ($opt{shuffle}) { + require List::Util; + $videos = [List::Util::shuffle(@{$videos})]; + } + + #~ if (@{$videos} and not $results->{has_extra_info}) { + + #~ my @video_ids = grep { defined } map { $yv_utils->get_video_id($_) } @{$videos}; + #~ my $content_details = $yv_obj->video_details(join(',', @video_ids), VIDEO_PART); + #~ my $video_details = $content_details->{results}{items}; + + #~ foreach my $i (0 .. $#{$videos}) { + #~ @{$videos->[$i]}{qw(id contentDetails statistics snippet)} = + #~ @{$video_details->[$i]}{qw(id contentDetails statistics snippet)}; + #~ } + + #~ $results->{has_extra_info} = 1; + #~ } + +#<<< + # Filter out private or deleted videos + #~ @$videos = grep { + #~ $yv_utils->get_video_id($_) + #~ and $yv_utils->get_time($_) ne '00:00' + #~ } @$videos; +#>>> + + my @formatted; + + foreach my $i (0 .. $#{$videos}) { + my $video = $videos->[$i]; + + #use Data::Dump qw(pp); + #pp $video; + + if ($opt{custom_layout}) { + + my $entry = $opt{custom_layout_format}; + + if (ref($entry) eq '') { + $entry =~ s/\*NO\*/sprintf('%2d', $i+1)/ge; + $entry = $yv_utils->format_text( + info => $video, + text => $entry, + escape => 0, + ); + push @formatted, "$entry\n"; + } + + if (ref($entry) eq 'ARRAY') { + + my @columns; + + foreach my $slot (@$entry) { + + my $text = $slot->{text}; + my $width = $slot->{width} // 10; + my $color = $slot->{color}; + my $align = $slot->{align} // 'left'; + + if ($width =~ /^(\d+)%\z/) { + $width = int(($term_width * $1) / 100); + } + + $text =~ s/\*NO\*/$i+1/ge; + + $text = $yv_utils->format_text( + info => $video, + text => $text, + escape => 0, + ); + + $text = clear_title($text); + $text = adjust_width($text, $width, ($align eq 'right')); + + if (defined($color)) { + $text = colored($text, $color); + } + + push @columns, $text; + } + + push @formatted, join(' ', @columns) . "\n"; + } + } + elsif ($opt{results_with_details}) { + push @formatted, + ($i == 0 ? '' : "\n") + . sprintf( + "%s. %s\n" . " %s: %-16s %s: %-13s %s: %s\n" . " %s: %-12s %s: %-10s %s: %s\n%s\n", + colored(sprintf('%2d', $i + 1), 'bold') => colored($yv_utils->get_title($video), 'bold blue'), + colored('Views' => 'bold') => $yv_utils->set_thousands($yv_utils->get_views($video)), + colored('Likes' => 'bold') => $yv_utils->set_thousands($yv_utils->get_likes($video)), + colored('Dislikes' => 'bold') => $yv_utils->set_thousands($yv_utils->get_dislikes($video)), + colored('Published' => 'bold') => $yv_utils->get_publication_date($video), + colored('Duration' => 'bold') => $yv_utils->get_time($video), + colored('Author' => 'bold') => $yv_utils->get_channel_title($video), + wrap_text( + i_tab => q{ } x 4, + s_tab => q{ } x 4, + text => [$yv_utils->get_description($video) || 'No description available...'] + ), + ); + } + elsif ($opt{results_with_colors}) { + my $definition = $yv_utils->get_definition($video); + push @formatted, + sprintf( + "%s. %s (%s) %s\n", + colored(sprintf('%2d', $i + 1), 'bold'), + colored($yv_utils->get_title($video), 'blue'), + colored($yv_utils->get_time($video), 'magenta'), + colored($yv_utils->get_channel_title($video), 'green'), + ); + } + elsif ($opt{results_fixed_width}) { + + require List::Util; + + my @durations = map { $yv_utils->get_duration($_) } @{$videos}; + my @authors = map { $yv_utils->get_channel_title($_) } @{$videos}; + + my $author_width = List::Util::min(List::Util::max(map { length($_) } @authors) || 1, int($term_width / 5)); + my $time_width = List::Util::first(sub { $_ >= 3600 }, @durations) ? 8 : 6; + my $title_length = $term_width - ($author_width + $time_width + 3 + 2 + 1); + + foreach my $i (0 .. $#{$videos}) { + + my $video = $videos->[$i]; + my $title = clear_title($yv_utils->get_title($video)); + + push @formatted, + sprintf("%s. %s %s %*s\n", + colored(sprintf('%2d', $i + 1), 'bold'), + adjust_width($title, $title_length), + adjust_width($yv_utils->get_channel_title($video), $author_width, 1), + $time_width, $yv_utils->get_time($video)); + } + last; + } + else { + push @formatted, + sprintf( + "%s. %s (by %s) [%s]\n", + colored(sprintf('%2d', $i + 1), 'bold'), $yv_utils->get_title($video), + $yv_utils->get_channel_title($video), $yv_utils->get_time($video), + ); + } + } + + if ($opt{highlight_watched}) { + foreach my $i (0 .. $#{$videos}) { + my $video = $videos->[$i]; + if (exists($watched_videos{$yv_utils->get_video_id($video)})) { + $formatted[$i] = colored(colorstrip($formatted[$i]), $opt{highlight_color}); + } + } + } + + if (@formatted) { + print "\n" . join("", @formatted); + } + + if ($opt{play_all} || $opt{play_backwards}) { + if (@{$videos}) { + if ( + play_videos( + $opt{play_backwards} + ? [reverse @{$videos}] + : $videos + ) + ) { + if ($opt{play_backwards}) { + if (defined($url)) { + __SUB__->($yv_obj->previous_page($url), auto => 1); + } + else { + $opt{play_backwards} = 0; + warn_first_page(); + return; + } + } + else { + if (defined($url)) { + __SUB__->($yv_obj->next_page($url), auto => 1); + } + else { + $opt{play_all} = 0; + warn_last_page(); + return; + } + } + } + else { + $opt{play_all} = 0; + $opt{play_backwards} = 0; + __SUB__->($results); + } + } + else { + $opt{play_all} = 0; + $opt{play_backwards} = 0; + } + } + + state @keywords; + if ($args{auto}) { } # do nothing... + else { + @keywords = get_input_for_search(); + + if (scalar(@keywords) == 0) { # only arguments + __SUB__->($results); + } + } + + state @for_search; + state @for_play; + + my @copy_of_keywords = @keywords; + my $contains_keywords = grep /$non_digit_or_opt_re/, @keywords; + + while (@keywords) { + my $key = shift @keywords; + if ($key =~ /$valid_opt_re/) { + + my $opt = $1; + + if ( + general_options(opt => $opt, + res => $videos,) + ) { + ## ok + } + elsif ($opt =~ /^(?:h|help)\z/) { + print $complete_help; + press_enter_to_continue(); + } + elsif ($opt =~ /^(?:n|next)\z/) { + if (defined($url)) { + my $request = $yv_obj->next_page($url); + __SUB__->($request, @keywords ? (auto => 1) : ()); + } + else { + warn_last_page(); + if ($opt{auto_next_page}) { + $opt{auto_next_page} = 0; + @copy_of_keywords = (); + last; + } + } + } + elsif ($opt =~ /^(?:b|back|p|prev|previous)\z/) { + if (defined($url)) { + __SUB__->($yv_obj->previous_page($url), @keywords ? (auto => 1) : ()); + } + else { + warn_first_page(); + } + } + elsif ($opt =~ /^(?:R|refresh)\z/) { + @{$videos} = @{$yv_obj->_get_results($url)->{results}}; + $results->{has_extra_info} = 0; + } + elsif ($opt =~ /^(?:r|return)\z/) { + return; + } + elsif ($opt =~ /^(?:a|author|u|uploads)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + foreach my $id (@nums) { + my $channel_id = $yv_utils->get_channel_id($videos->[$id]); + my $request = $yv_obj->uploads($channel_id); + if ($yv_utils->has_entries($request)) { + __SUB__->($request); + } + else { + warn_no_results('video'); + } + } + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^(?:pv|popular)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + foreach my $id (@nums) { + my $channel_id = $yv_utils->get_channel_id($videos->[$id]); + my $request = $yv_obj->popular_videos($channel_id); + if ($yv_utils->has_entries($request)) { + __SUB__->($request); + } + else { + warn_no_results('popular video'); + } + } + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^(?:A|[Aa]ctivity)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + foreach my $id (@nums) { + my $channel_id = $yv_utils->get_channel_id($videos->[$id]); + my $request = $yv_obj->activities($channel_id); + if ($yv_utils->has_entries($request)) { + __SUB__->($request); + } + else { + warn_no_results('activity'); + } + } + } + else { + warn_no_thing_selected('activity'); + } + } + elsif ($opt =~ /^(?:ps|s2p)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + select_and_save_to_playlist(map { $yv_utils->get_video_id($videos->[$_]) } @nums); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^(?:p|playlists?|up)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + foreach my $id (@nums) { + my $request = $yv_obj->playlists($yv_utils->get_channel_id($videos->[$id])); + if ($yv_utils->has_entries($request)) { + print_playlists($request); + } + else { + warn_no_results('playlist'); + } + } + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^((?:dis)?like)${digit_or_equal_re}(.*)/) { + my $rating = $1; + if (my @nums = get_valid_numbers($#{$videos}, $2)) { + rate_videos($rating, map { $yv_utils->get_video_id($videos->[$_]) } @nums); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^(?:fav|favorite|F)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + favorite_videos(map { $yv_utils->get_video_id($videos->[$_]) } @nums); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^(?:subscribe|S)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + subscribe(map { $yv_utils->get_channel_id($videos->[$_]) } @nums); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^(?:q|queue|enqueue)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + push @{$opt{_queue_play}}, map { $yv_utils->get_video_id($videos->[$_]) } @nums; + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^(?:pq|qp|play-queue)\z/) { + if (ref $opt{_queue_play} eq 'ARRAY' and @{$opt{_queue_play}}) { + my $ids = 'v=' . join(q{,}, splice @{$opt{_queue_play}}); + general_options(opt => $ids); + } + else { + warn colored("\n[!] The playlist is empty!", 'bold red') . "\n"; + } + } + elsif ($opt =~ /^c(?:omments?)?${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + get_and_print_comments(map { $yv_utils->get_video_id($videos->[$_]) } @nums); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^r(?:elated)?${digit_or_equal_re}(.*)/) { + if (my ($id) = get_valid_numbers($#{$videos}, $1)) { + get_and_print_related_videos($yv_utils->get_video_id($videos->[$id])); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^(?:ap|autoplay)${digit_or_equal_re}(.*)/) { + if (my ($id) = get_valid_numbers($#{$videos}, $1)) { + local $opt{autoplay_mode} = 1; + play_videos([$videos->[$id]]); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^d(?:ownload)?${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + local $opt{download_video} = 1; + play_videos([@{$videos}[@nums]]); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^(?:play|P)${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + local $opt{download_video} = 0; + local $opt{extract_info} = undef; + play_videos([@{$videos}[@nums]]); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt =~ /^i(?:nfo)?${digit_or_equal_re}(.*)/) { + if (my @nums = get_valid_numbers($#{$videos}, $1)) { + foreach my $num (@nums) { + local $opt{show_video_info} = 1; + print_video_info($videos->[$num]); + } + press_enter_to_continue(); + } + else { + warn_no_thing_selected('video'); + } + } + elsif ($opt eq 'anp') { # auto-next-page + $opt{auto_next_page} = 1; + } + elsif ($opt eq 'nnp') { # no-next-page + $opt{auto_next_page} = 0; + } + elsif ($opt =~ /^[ks]re(?:gex)?=(.*)/) { + my $value = $1; + if ($value =~ /^([a-zA-Z]++)(?>,|=>)(.+)/) { + play_videos_matched_by_regex( + key => $1, + regex => $2, + videos => $videos, + ) + or __SUB__->($results); + } + else { + warn_invalid("Special Regexp", $value); + } + } + elsif ($opt =~ /^re(?:gex)?=(.*)/) { + play_videos_matched_by_regex( + key => 'title', + regex => $1, + videos => $videos, + ) + or __SUB__->($results); + } + else { + warn_invalid('option', $opt); + } + } + elsif (youtube_urls($key)) { + ## ok + } + elsif (!$contains_keywords and (valid_num($key, $videos) or $key =~ /$range_num_re/)) { + my @for_play; + if ($key =~ /$range_num_re/) { + my $from = $1; + my $to = $2 // do { + $opt{auto_next_page} ? do { $from = 1 } : do { $opt{auto_next_page} = 1 }; + $#{$videos} + 1; + }; + my @ids = get_valid_numbers($#{$videos}, "$from..$to"); + if (@ids) { + push @for_play, @ids; + } + else { + push @for_search, $key; + } + } + else { + push @for_play, $key - 1; + } + + if (@for_play and not play_videos([@{$videos}[@for_play]])) { + __SUB__->($results); + } + } + else { + push @for_search, $key; + } + } + + if (@for_search) { + __SUB__->($yv_obj->search_videos([splice(@for_search)])); + } + elsif ($opt{auto_next_page}) { + @keywords = (':next', grep { $_ !~ /^:(n|next|anp)\z/ } @copy_of_keywords); + + if (@keywords > 1) { + my $timeout = 2; + print colored("\n:: Press in $timeout seconds to stop the :anp option.", 'bold green'); + eval { + local $SIG{ALRM} = sub { + die "alarm\n"; + }; + alarm $timeout; + scalar ; + alarm 0; + }; + + if ($@) { + if ($@ eq "alarm\n") { + __SUB__->($results, auto => 1); + } + else { + warn colored("\n[!] Unexpected error: <$@>.", 'bold red') . "\n"; + } + } + else { + $opt{auto_next_page} = 0; + __SUB__->($results); + } + } + else { + warn colored("\n[!] Option ':anp' works only combined with other options!", 'bold red') . "\n"; + $opt{auto_next_page} = 0; + __SUB__->($results); + } + } + + __SUB__->($results) if not $args{auto}; + + return 1; +} + +sub press_enter_to_continue { + say ''; + scalar $term->readline(colored("=>> Press ENTER to continue...", 'bold')); +} + +sub main_quit { + exit($_[0] // 0); +} + +main_quit(0); + +=head1 CONFIGURATION OPTIONS + +=head2 api_host + +Hostname of an invidious instance. When set to C<"auto">, a random invidious is selected everytime when the program is started. + +=head2 auto_captions + +When set to C<1>, auto-generated captions will be retrieved. By default, auto-generated captions are ignored. + +=head2 autoplay_mode + +Enable autoplay mode, which will continuously play related videos. + +=head2 cache_dir + +Cache directory where to save temporary files. + +=head2 colors + +Use colors for text. + +=head2 comments_order + +The sorting order for comments. Valid values: "top", "new". + +=head2 confirm + +Display a confirmation message after each video played. + +=head2 convert_cmd + +Command to convert videos. + +Default value: + + "ffmpeg -i *IN* *OUT*" + +B<*IN*> gets replaced with the input file. + +B<*OUT*> gets replaced with the output file. + +=head2 convert_to + +Format to convert each downloaded video into. (e.g.: C<"mp3">). + +=head2 cookie_file + +Load cookies from a file. Useful to overcome the "429: Too Many Requests" issue. + +The file must be a C<# Netscape HTTP Cookie File>. Same format as C requires. + +See also: + + https://github.com/ytdl-org/youtube-dl#how-do-i-pass-cookies-to-youtube-dl + +=head2 copy_caption + +When downloading a video, copy the closed-caption (if any) in the same folder with the video. + +If C and C are both enabled, there is no need to enable this option. + +=head2 custom_layout + +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 + text # the actual text + width # width allocated for the text + +The value for C can be either a number of characters (e.g.: 20) or can be a percentage of the terminal width (e.g.: "15%"). + +The special tokens for C are listed in: + + pipe-viewer --tricks + +=head2 dash_mp4_audio + +Include or exclude MP4/M4A (AAC) audio files. + +=head2 dash_segmented + +Include or exclude streams in "Dynamic Adaptive Streaming over HTTP" (DASH) format. + +=head2 dash_support + +Enable or disable support for split videos. + +=head2 date + +Search for videos uploaded within a specific amount of time. + +Valid values: "hour", "today", "week", "month", "year". + +=head2 debug + +Enable debug/verbose mode, which will print some extra information. + +Valid values: 0, 1, 2, 3. + +=head2 download_and_play + +Play downloaded videos. + +=head2 download_with_wget + +Download videos with C. + +=head2 downloads_dir + +Directory where to download files and where to save converted files. + +=head2 env_proxy + +Load proxy settings from C<*_proxy> environment variables (if any). + +=head2 fat32safe + +When downloading a video, make the filename compatible with the FAT32 filesystem. + +=head2 ffmpeg_cmd + +Path to the C program. + +=head2 fullscreen + +Play videos in fullscreen mode. + +=head2 get_captions + +Download closed-captions for videos (if any). + +=head2 get_term_width + +Read the terminal width (`stty size`). + +=head2 hfr + +Include or exclude High Frame Rate (HFR) videos. + +=head2 highlight_color + +Highlight color used to highlight watched videos. + +Any color supported by L can be used. + +=head2 highlight_watched + +Highlight watched videos. + +=head2 history + +Enable or disable support for input history. + +Requires L. + +=head2 history_file + +File where to save the input history. + +=head2 history_limit + +Maximum number of entries in the history file. + +When the limit is reached, the first half of the history file will be deleted. + +Set the value to C<-1> for no limit. + +=head2 http_proxy + +Set HTTP(S)/SOCKS proxy, using the format: + + proto://domain.tld:port/ + +If authentication is required, use: + + proto://user:pass@domain.tld:port/ + +=head2 ignore_av1 + +Ignore videos in AV1 format. + +=head2 interactive + +Interactive mode, prompting for user-input. + +=head2 keep_original_video + +Keep the original video after conversion. When set to C<0>, the original video will be deleted. + +=head2 maxResults + +How many results to display per page. + +Currently, this is not implemented. + +=head2 merge_into_mkv + +During download, merge the audio+video files into an MKV container. + +Requires C. + +=head2 merge_into_mkv_args + +Arguments for C how to merge the files. + +=head2 merge_with_captions + +Include closed-captions inside the MKV container (if any). + +=head2 order + +Search order for videos. + +Valid values: "relevance", "rating", "upload_date", "view_count". + +=head2 page + +Page number of results. + +=head2 prefer_av1 + +Prefer videos in AV1 format. (just for testing) + +=head2 prefer_mp4 + +Prefer videos in MP4 (AVC) format. + +=head2 region + +ISO 3166 country code (default: "US"). + +=head2 remember_watched + +Set to C<1> to remember and highlight watched videos across multiple sessions. + +The video IDs are saved in the filename specified by C. + +=head2 remove_played_file + +When C is enabled, remove the file after playing it. + +=head2 resolution + +Preferred resolution for videos. + +Valid values: best, 2160p, 1440p, 1080p, 720p, 480p, 360p, 240p, 144p, audio. + +=head2 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. + +=head2 skip_if_exists + +When downloading, skip if the file already exists locally. + +=head2 skip_watched + +Skip already watched/downloaded videos. + +=head2 srt_languages + +List of SRT languages in the order of preference. + +=head2 subscriptions_order + +Order of subscriptions. Currently, not implemented. + +=head2 thousand_separator + +Thousands separator character for numbers >= 1000. + +=head2 timeout + +HTTPS timeout value in seconds. The default value is 10 seconds. + +=head2 user_agent + +Token that is used to identify the user agent on the network. The agent value is sent as the C header in the requests. + +=head2 video_filename_format + +The format of filename for downloaded files. + +The available special tokens are listed in: + + pipe-viewer --tricks + +=head2 video_player_selected + +The selected video player defined the C table. + +=head2 video_players + +A table of video players. + +The keys for each player are: + + arg # any arguments for the video player + audio # option specifying the *AUDIO* file + cmd # the main player command + fs # the fullscreen option + novideo # the no-video mode option + srt # option specifying the *SUB* file + +=head2 videoCaption + +When set to C<1> or C<"true">, retrieve only the videos that contain closed-captions in search results. + +=head2 videoDefinition + +When set to C<"high">, retrieve only HD videos in search results. + +=head2 videoDimension + +When set to C<"3d">, retrieve only 3D videos in search results. + +=head2 videoDuration + +Retrieve only short or long videos in search results. + +Valid values: "any", "short", "long". + +=head2 videoLicense + +When set to C<"creative_commons">, retrieve only videos under the I license in search results. + +=head2 watched_file + +File where to save the video IDs of watched/downloaded videos when C is set to a true value. + +=head2 wget_cmd + +Command for C when C is set to a true value. + +=head2 youtube_video_url + +Format for C for constructing an YouTube video URL given the video ID. + +=head2 ytdl + +Use C for videos with encrypted signatures. + +When set to C<0>, invidious instances will be used instead. + +=head2 ytdl_cmd + +Command for C when C is set to a true value. + +=head1 CONFIGURATION FILES + +The configuration files are: + + ~/.config/pipe-viewer/pipe-viewer.conf + ~/.config/pipe-viewer/gtk-pipe-viewer.conf + +=head1 INVIDIOUS API REFERENCE + +https://github.com/iv-org/invidious/wiki/API + +=head1 REPOSITORY + +https://github.com/trizen/pipe-viewer + +=head1 LICENSE AND COPYRIGHT + +Copyright 2010-2020 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + +See L for more information. + +=cut diff --git a/lib/WWW/PipeViewer.pm b/lib/WWW/PipeViewer.pm new file mode 100644 index 0000000..5b01c46 --- /dev/null +++ b/lib/WWW/PipeViewer.pm @@ -0,0 +1,1294 @@ +package WWW::PipeViewer; + +use utf8; +use 5.016; +use warnings; + +use Memoize; + +memoize('_get_video_info'); +memoize('_ytdl_is_available'); +memoize('_extract_from_ytdl'); +memoize('_extract_from_invidious'); + +use parent qw( + WWW::PipeViewer::Search + WWW::PipeViewer::Videos + WWW::PipeViewer::Channels + WWW::PipeViewer::Playlists + WWW::PipeViewer::ParseJSON + WWW::PipeViewer::Activities + WWW::PipeViewer::Subscriptions + WWW::PipeViewer::PlaylistItems + WWW::PipeViewer::CommentThreads + WWW::PipeViewer::Authentication + WWW::PipeViewer::VideoCategories + ); + +=head1 NAME + +WWW::PipeViewer - A very easy interface to YouTube, using the API of invidio.us. + +=cut + +our $VERSION = '0.0.1'; + +=head1 SYNOPSIS + + use WWW::PipeViewer; + + my $yv_obj = WWW::PipeViewer->new(); + ... + +=head1 SUBROUTINES/METHODS + +=cut + +my %valid_options = ( + + # Main options + v => {valid => q[], default => 3}, + page => {valid => qr/^(?!0+\z)\d+\z/, default => 1}, + http_proxy => {valid => qr/./, default => undef}, + maxResults => {valid => [1 .. 50], default => 10}, + order => {valid => [qw(relevance rating upload_date view_count)], default => undef}, + date => {valid => [qw(hour today week month year)], default => undef}, + + channelId => {valid => qr/^[-\w]{2,}\z/, default => undef}, + + # Video only options + videoCaption => {valid => [qw(1 true)], default => undef}, + videoDefinition => {valid => [qw(high standard)], default => undef}, + videoDimension => {valid => [qw(2d 3d)], default => undef}, + videoDuration => {valid => [qw(short long)], default => undef}, + videoLicense => {valid => [qw(creative_commons)], default => undef}, + region => {valid => qr/^[A-Z]{2}\z/i, default => undef}, + + comments_order => {valid => [qw(top new)], default => 'top'}, + subscriptions_order => {valid => [qw(alphabetical relevance unread)], default => undef}, + + # Misc + debug => {valid => [0 .. 3], default => 0}, + timeout => {valid => qr/^\d+\z/, default => 10}, + config_dir => {valid => qr/^./, default => q{.}}, + cache_dir => {valid => qr/^./, default => q{.}}, + cookie_file => {valid => qr/^./, default => undef}, + + # Support for youtube-dl + ytdl => {valid => [1, 0], default => 1}, + ytdl_cmd => {valid => qr/\w/, default => "youtube-dl"}, + + # 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}, + + # API/OAuth + key => {valid => qr/^.{15}/, default => undef}, + client_id => {valid => qr/^.{15}/, default => undef}, + client_secret => {valid => qr/^.{15}/, default => undef}, + redirect_uri => {valid => qr/^.{15}/, default => undef}, + access_token => {valid => qr/^.{15}/, default => undef}, + refresh_token => {valid => qr/^.{15}/, default => undef}, + + 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'}, + www_content_type => {valid => q[], default => 'application/x-www-form-urlencoded'}, + m_youtube_url => {valid => q[], default => 'https://m.youtube.com'}, + +#<<< + # LWP user agent + 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'}, +#>>> +); + +sub _our_smartmatch { + my ($value, $arg) = @_; + + $value // return 0; + + if (not ref($arg)) { + return ($value eq $arg); + } + + if (ref($arg) eq ref(qr//)) { + return scalar($value =~ $arg); + } + + if (ref($arg) eq 'ARRAY') { + foreach my $item (@$arg) { + return 1 if __SUB__->($value, $item); + } + } + + return 0; +} + +sub basic_video_info_fields { + join( + ',', + qw( + title + videoId + description + descriptionHtml + published + publishedText + viewCount + likeCount + dislikeCount + genre + author + authorId + lengthSeconds + rating + liveNow + ) + ); +} + +sub extra_video_info_fields { + my ($self) = @_; + join( + ',', + $self->basic_video_info_fields, + qw( + subCountText + captions + isFamilyFriendly + ) + ); +} + +{ + no strict 'refs'; + + foreach my $key (keys %valid_options) { + + if (ref($valid_options{$key}{valid})) { + + # Create the 'set_*' subroutines + *{__PACKAGE__ . '::set_' . $key} = sub { + my ($self, $value) = @_; + $self->{$key} = + _our_smartmatch($value, $valid_options{$key}{valid}) + ? $value + : $valid_options{$key}{default}; + }; + } + + # Create the 'get_*' subroutines + *{__PACKAGE__ . '::get_' . $key} = sub { + my ($self) = @_; + + if (not exists $self->{$key}) { + return ($self->{$key} = $valid_options{$key}{default}); + } + + $self->{$key}; + }; + } +} + +=head2 new(%opts) + +Returns a blessed object. + +=cut + +sub new { + my ($class, %opts) = @_; + + my $self = bless {}, $class; + + foreach my $key (keys %valid_options) { + if (exists $opts{$key}) { + my $method = "set_$key"; + $self->$method(delete $opts{$key}); + } + } + + foreach my $invalid_key (keys %opts) { + warn "Invalid key: '${invalid_key}'"; + } + + return $self; +} + +sub page_token { + my ($self) = @_; + my $page = $self->get_page; + return undef if ($page == 1); + return $page; +} + +=head2 escape_string($string) + +Escapes a string with URI::Escape and returns it. + +=cut + +sub escape_string { + my ($self, $string) = @_; + + require URI::Escape; + + $self->get_escape_utf8 + ? URI::Escape::uri_escape_utf8($string) + : URI::Escape::uri_escape($string); +} + +=head2 set_lwp_useragent() + +Initializes the LWP::UserAgent module and returns it. + +=cut + +sub set_lwp_useragent { + my ($self) = @_; + + my $lwp = ( + eval { require LWP::UserAgent::Cached; 'LWP::UserAgent::Cached' } + // do { require LWP::UserAgent; 'LWP::UserAgent' } + ); + + my $agent = $lwp->new( + + cookie_jar => {}, # temporary cookies + timeout => $self->get_timeout, + show_progress => $self->get_debug, + agent => $self->get_user_agent, + + ssl_opts => {verify_hostname => 1}, + + $lwp eq 'LWP::UserAgent::Cached' + ? ( + cache_dir => $self->get_cache_dir, + nocache_if => sub { + my ($response) = @_; + my $code = $response->code; + + $code >= 300 # do not cache any bad response + or $response->request->method ne 'GET' # cache only GET requests + + # don't cache if "cache-control" specifies "max-age=0" or "no-store" + or (($response->header('cache-control') // '') =~ /\b(?:max-age=0|no-store)\b/) + + # don't cache video or audio files + or (($response->header('content-type') // '') =~ /\b(?:video|audio)\b/); + }, + + recache_if => sub { + my ($response, $path) = @_; + not($response->is_fresh) # recache if the response expired + or ($response->code == 404 && -M $path > 1); # recache any 404 response older than 1 day + } + ) + : (), + + env_proxy => (defined($self->get_http_proxy) ? 0 : $self->get_env_proxy), + ); + + require LWP::ConnCache; + state $cache = LWP::ConnCache->new; + $cache->total_capacity(undef); # no limit + + state $accepted_encodings = do { + require HTTP::Message; + HTTP::Message::decodable(); + }; + + $agent->ssl_opts(Timeout => $self->get_timeout); + $agent->default_header('Accept-Encoding' => $accepted_encodings); + $agent->conn_cache($cache); + $agent->proxy(['http', 'https'], $self->get_http_proxy) if defined($self->get_http_proxy); + + my $cookie_file = $self->get_cookie_file; + + if (defined($cookie_file) and -f $cookie_file) { + + if ($self->get_debug) { + say STDERR ":: Using cookies from: $cookie_file"; + } + + ## 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://github.com/ytdl-org/youtube-dl#how-do-i-pass-cookies-to-youtube-dl + + require HTTP::Cookies::Netscape; + + my $cookies = HTTP::Cookies::Netscape->new( + hide_cookie2 => 1, + autosave => 1, + file => $cookie_file, + ); + + $cookies->load; + $agent->cookie_jar($cookies); + } + + push @{$agent->requests_redirectable}, 'POST'; + $self->{lwp} = $agent; + return $agent; +} + +=head2 prepare_access_token() + +Returns a string. used as header, with the access token. + +=cut + +sub prepare_access_token { + my ($self) = @_; + + if (defined(my $auth = $self->get_access_token)) { + return "Bearer $auth"; + } + + return; +} + +sub _auth_lwp_header { + my ($self) = @_; + + my %lwp_header; + if (defined $self->get_access_token) { + $lwp_header{'Authorization'} = $self->prepare_access_token; + } + + return %lwp_header; +} + +sub _warn_reponse_error { + my ($resp, $url) = @_; + warn sprintf("[%s] Error occurred on URL: %s\n", $resp->status_line, $url); +} + +=head2 lwp_get($url, %opt) + +Get and return the content for $url. + +Where %opt can be: + + simple => [bool] + +When the value of B is set to a true value, the +authentication header will not be set in the HTTP request. + +=cut + +sub lwp_get { + my ($self, $url, %opt) = @_; + + $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 ($response->is_success) { + return $response->decoded_content; + } + + 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}) { + + $self->set_access_token($refresh_token->{access_token}); + + # Don't be tempted to use recursion here, because bad things will happen! + $response = $self->{lwp}->get($url, $self->_auth_lwp_header); + + if ($response->is_success) { + $self->save_authentication_tokens(); + return $response->decoded_content; + } + 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 { + 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(); + } + } + + $opt{depth} ||= 0; + + # Try again on 500+ HTTP errors + if ( $opt{depth} < 3 + 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(); + + _warn_reponse_error($response, $url); + return; +} + +=head2 lwp_post($url, [@args]) + +Post and return the content for $url. + +=cut + +sub lwp_post { + my ($self, $url, @args) = @_; + + $self->{lwp} // $self->set_lwp_useragent(); + + my $response = $self->{lwp}->post($url, @args); + + if ($response->is_success) { + return $response->decoded_content; + } + else { + _warn_reponse_error($response, $url); + } + + return; +} + +=head2 lwp_mirror($url, $output_file) + +Downloads the $url into $output_file. Returns true on success. + +=cut + +sub lwp_mirror { + my ($self, $url, $output_file) = @_; + $self->{lwp} // $self->set_lwp_useragent(); + $self->{lwp}->mirror($url, $output_file); +} + +sub _get_results { + my ($self, $url, %opt) = @_; + + return + scalar { + url => $url, + results => $self->parse_json_string($self->lwp_get($url, %opt)), + }; +} + +=head2 list_to_url_arguments(\%options) + +Returns a valid string of arguments, with defined values. + +=cut + +sub list_to_url_arguments { + my ($self, %args) = @_; + join(q{&}, map { "$_=$args{$_}" } grep { defined $args{$_} } sort keys %args); +} + +sub _append_url_args { + my ($self, $url, %args) = @_; + %args + ? ($url . ($url =~ /\?/ ? '&' : '?') . $self->list_to_url_arguments(%args)) + : $url; +} + +sub get_invidious_instances { + my ($self) = @_; + + require File::Spec; + my $instances_file = File::Spec->catfile($self->get_config_dir, 'instances.json'); + + # Get the "instances.json" file when the local copy is too old or non-existent + if ((not -e $instances_file) or (-M _) > 1 / 24) { + + require LWP::UserAgent; + + 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"); + + $resp->is_success() or return; + + my $json = $resp->decoded_content() || return; + open(my $fh, '>', $instances_file) or return; + print $fh $json; + close $fh; + } + + open(my $fh, '<', $instances_file) or return; + + my $json_string = do { + local $/; + <$fh>; + }; + + $self->parse_json_string($json_string); +} + +sub select_good_invidious_instances { + my ($self) = @_; + + state $instances = $self->get_invidious_instances; + + ref($instances) eq 'ARRAY' or return; + + my %ignored = ( + 'yewtu.be' => 1, + 'invidiou.site' => 1, + 'invidious.xyz' => 1, + 'vid.mint.lgbt' => 1, + 'invidious.ggc-project.de' => 1, + 'invidious.toot.koeln' => 1, + 'invidious.kavin.rocks' => 1, + 'invidious.snopyta.org' => 0, + ); + + my @candidates = + grep { not $ignored{$_->[0]} } + grep { ref($_->[1]{monitor}) eq 'HASH' ? ($_->[1]{monitor}{statusClass} eq 'success') : 1 } + grep { lc($_->[1]{type} // '') eq 'https' } @$instances; + + if ($self->get_debug) { + + my @hosts = map { $_->[0] } @candidates; + my $count = scalar(@candidates); + + print STDERR ":: Found $count invidious instances: @hosts\n"; + } + + return @candidates; +} + +sub pick_random_instance { + my ($self) = @_; + my @candidates = $self->select_good_invidious_instances(); + $candidates[rand @candidates]; +} + +sub pick_and_set_random_instance { + my ($self) = @_; + + my $instance = $self->pick_random_instance() // return; + + ref($instance) eq 'ARRAY' or return; + + my $uri = $instance->[1]{uri} // return; + $uri =~ s{/+\z}{}; # remove trailing '/' + + $self->set_api_host($uri); +} + +sub get_api_url { + my ($self) = @_; + + my $host = $self->get_api_host; + + # Remove whitespace (if any) + $host =~ s/^\s+//; + $host =~ s/\s+\z//; + + $host =~ s{/+\z}{}; # remove trailing '/' + + if ($host =~ m{^[-\w]+(?>\.[-\w]+)+\z}) { # no protocol specified + $host = 'https://' . $host; # default to HTTPS + } + + # Pick a random instance when `--instance=auto` or `--instance=invidio.us`. + if ($host eq 'auto' or $host =~ m{^https://(?:www\.)?invidio\.us\b}) { + + if (defined($self->pick_and_set_random_instance())) { + $host = $self->get_api_host(); + print STDERR ":: Changed the instance to: $host\n" if $self->get_debug; + } + else { + $host = "https://invidious.snopyta.org"; + $self->set_api_host($host); + print STDERR ":: Failed to change the instance. Using: $host\n" if $self->get_debug; + } + } + + join('', $host, $self->get_api_path); +} + +sub _simple_feeds_url { + my ($self, $path, %args) = @_; + $self->get_api_url . $path . '?' . $self->list_to_url_arguments(key => $self->get_key, %args); +} + +=head2 default_arguments(%args) + +Merge the default arguments with %args and concatenate them together. + +=cut + +sub default_arguments { + my ($self, %args) = @_; + + my %defaults = ( + + #key => $self->get_key, + #part => 'snippet', + #prettyPrint => 'false', + #maxResults => $self->get_maxResults, + %args, + ); + + $self->list_to_url_arguments(%defaults); +} + +sub _make_feed_url { + my ($self, $path, %args) = @_; + + my $extra_args = $self->default_arguments(%args); + my $url = $self->get_api_url . $path; + + if ($extra_args) { + $url .= '?' . $extra_args; + } + + return $url; +} + +sub _extract_from_invidious { + my ($self, $videoID) = @_; + + my @instances = $self->select_good_invidious_instances(); + + if (@instances) { + require List::Util; + @instances = List::Util::shuffle(map { $_->[0] } @instances); + push @instances, 'invidious.snopyta.org'; + } + else { + @instances = qw( + invidious.tube + invidious.site + invidious.fdn.fr + invidious.snopyta.org + ); + } + + if ($self->get_debug) { + print STDERR ":: Invidious instances: @instances\n"; + } + + my $tries = 2 * scalar(@instances); + my $instance = shift(@instances); + my $url_format = "https://%s/api/v1/videos/%s?fields=formatStreams,adaptiveFormats"; + my $url = sprintf($url_format, $instance, $videoID); + + my $resp = $self->{lwp}->get($url); + + while (not $resp->is_success() and --$tries >= 0) { + $url = sprintf($url_format, shift(@instances), $videoID) if (@instances and ($tries % 2 == 0)); + $resp = $self->{lwp}->get($url); + } + + $resp->is_success() || return; + + my $json = $resp->decoded_content() // return; + my $ref = $self->parse_json_string($json) // return; + + my @formats; + + # The entries are already in the format that we want. + if (exists($ref->{adaptiveFormats}) and ref($ref->{adaptiveFormats}) eq 'ARRAY') { + push @formats, @{$ref->{adaptiveFormats}}; + } + + if (exists($ref->{formatStreams}) and ref($ref->{formatStreams}) eq 'ARRAY') { + push @formats, @{$ref->{formatStreams}}; + } + + return @formats; +} + +sub _ytdl_is_available { + my ($self) = @_; + ($self->proxy_stdout($self->get_ytdl_cmd(), '--version') // '') =~ /\d/; +} + +sub _extract_from_ytdl { + my ($self, $videoID) = @_; + + $self->_ytdl_is_available() || return; + + my @ytdl_cmd = ($self->get_ytdl_cmd(), '--all-formats', '--dump-single-json'); + + my $cookie_file = $self->get_cookie_file; + + if (defined($cookie_file) and -f $cookie_file) { + push @ytdl_cmd, '--cookies', quotemeta($cookie_file); + } + + my $json = $self->proxy_stdout(@ytdl_cmd, quotemeta("https://www.youtube.com/watch?v=" . $videoID)); + my $ref = $self->parse_json_string($json); + + 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})) { + + my $entry = { + itag => $format->{format_id}, + url => $format->{url}, + type => ((($format->{format} // '') =~ /audio only/i) ? 'audio/' : 'video/') . $format->{ext}, + }; + + push @formats, $entry; + } + } + } + + return @formats; +} + +sub _fallback_extract_urls { + my ($self, $videoID) = @_; + + my @formats; + + # Use youtube-dl + if ($self->get_ytdl and $self->_ytdl_is_available) { + + if ($self->get_debug) { + say STDERR ":: Using youtube-dl to extract the streaming URLs..."; + } + + push @formats, $self->_extract_from_ytdl($videoID); + + if ($self->get_debug) { + my $count = scalar(@formats); + say STDERR ":: youtube-dl: found $count streaming URLs..."; + } + + @formats && return @formats; + } + + # Use the API of invidio.us + if ($self->get_debug) { + say STDERR ":: Using invidio.us to extract the streaming URLs..."; + } + + push @formats, $self->_extract_from_invidious($videoID); + + if ($self->get_debug) { + my $count = scalar(@formats); + say STDERR ":: invidious: found $count streaming URLs..."; + } + + return @formats; +} + +=head2 parse_query_string($string, multi => [0,1]) + +Parse a query string and return a data structure back. + +When the B option is set to a true value, the function will store multiple values for a given key. + +Returns back a list of key-value pairs. + +=cut + +sub parse_query_string { + my ($self, $str, %opt) = @_; + + if (not defined($str)) { + return; + } + + require URI::Escape; + + my @pairs; + foreach my $statement (split(/,/, $str)) { + foreach my $pair (split(/&/, $statement)) { + push @pairs, $pair; + } + } + + my %result; + + foreach my $pair (@pairs) { + my ($key, $value) = split(/=/, $pair, 2); + + if (not defined($value) or $value eq '') { + next; + } + + $value = URI::Escape::uri_unescape($value =~ tr/+/ /r); + + if ($opt{multi}) { + push @{$result{$key}}, $value; + } + else { + $result{$key} = $value; + } + } + + return %result; +} + +sub _group_keys_with_values { + my ($self, %data) = @_; + + my @hashes; + + foreach my $key (keys %data) { + foreach my $i (0 .. $#{$data{$key}}) { + $hashes[$i]{$key} = $data{$key}[$i]; + } + } + + return @hashes; +} + +sub _check_streaming_urls { + my ($self, $videoID, $results) = @_; + + foreach my $video (@$results) { + + if ( exists $video->{s} + or exists $video->{signatureCipher} + or exists $video->{cipher}) { # has an encrypted signature :( + + if ($self->get_debug) { + say STDERR ":: Detected an encrypted signature..."; + } + + my @formats = $self->_fallback_extract_urls($videoID); + + foreach my $format (@formats) { + foreach my $ref (@$results) { + if (defined($ref->{itag}) and ($ref->{itag} eq $format->{itag})) { + $ref->{url} = $format->{url}; + last; + } + } + } + + last; + } + } + + foreach my $video (@$results) { + if (exists $video->{mimeType}) { + $video->{type} = $video->{mimeType}; + } + } + + return 1; +} + +sub _old_extract_streaming_urls { + my ($self, $info, $videoID) = @_; + + if ($self->get_debug) { + say STDERR ":: Using `url_encoded_fmt_stream_map` to extract the streaming URLs..."; + } + + my %stream_map = $self->parse_query_string($info->{url_encoded_fmt_stream_map}, multi => 1); + my %adaptive_fmts = $self->parse_query_string($info->{adaptive_fmts}, multi => 1); + + if ($self->get_debug >= 2) { + require Data::Dump; + Data::Dump::pp(\%stream_map); + Data::Dump::pp(\%adaptive_fmts); + } + + my @results; + + push @results, $self->_group_keys_with_values(%stream_map); + push @results, $self->_group_keys_with_values(%adaptive_fmts); + + $self->_check_streaming_urls($videoID, \@results); + + if ($info->{livestream} or $info->{live_playback}) { + + if ($self->get_debug) { + say STDERR ":: Live stream detected..."; + } + + if (my @formats = $self->_fallback_extract_urls($videoID)) { + @results = @formats; + } + elsif (exists $info->{hlsvp}) { + push @results, + { + itag => 38, + type => 'video/ts', + url => $info->{hlsvp}, + }; + } + } + + return @results; +} + +sub _extract_streaming_urls { + my ($self, $info, $videoID) = @_; + + if (exists $info->{url_encoded_fmt_stream_map}) { + return $self->_old_extract_streaming_urls($info, $videoID); + } + + if ($self->get_debug) { + say STDERR ":: Using `player_response` to extract the streaming URLs..."; + } + + my $json = $self->parse_json_string($info->{player_response} // return); + + if ($self->get_debug >= 2) { + require Data::Dump; + Data::Dump::pp($json); + } + + ref($json) eq 'HASH' or return; + + my @results; + if (exists $json->{streamingData}) { + + my $streamingData = $json->{streamingData}; + + if (defined $streamingData->{dashManifestUrl}) { + say STDERR ":: Contains DASH manifest URL" if $self->get_debug; + ##return; + } + + if (exists $streamingData->{adaptiveFormats}) { + push @results, @{$streamingData->{adaptiveFormats}}; + } + + if (exists $streamingData->{formats}) { + push @results, @{$streamingData->{formats}}; + } + } + + $self->_check_streaming_urls($videoID, \@results); + + if (grep { $_->{url} =~ /\bsc=yes\b/ } @results) { + say STDERR ":: Contains SC = yes" if $self->get_debug; + ##return; + } + + # Keep only streams with contentLength > 0. + @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; + + # Detect livestream + if (!@results and exists($json->{streamingData}) and exists($json->{streamingData}{hlsManifestUrl})) { + + if ($self->get_debug) { + say STDERR ":: Live stream detected..."; + } + + @results = $self->_fallback_extract_urls($videoID); + + if (!@results) { + push @results, + { + itag => 38, + type => "video/ts", + url => $json->{streamingData}{hlsManifestUrl}, + }; + } + } + + return @results; +} + +sub _get_video_info { + my ($self, $videoID) = @_; + + my $url = $self->get_video_info_url() . sprintf($self->get_video_info_args(), $videoID); + my $content = $self->lwp_get($url, simple => 1) // return; + my %info = $self->parse_query_string($content); + + return %info; +} + +=head2 get_streaming_urls($videoID) + +Returns a list of streaming URLs for a videoID. +({itag=>..., url=>...}, {itag=>..., url=>....}, ...) + +=cut + +sub get_streaming_urls { + my ($self, $videoID) = @_; + + my %info = $self->_get_video_info($videoID); + my @streaming_urls = $self->_extract_streaming_urls(\%info, $videoID); + + my @caption_urls; + if (exists $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}}; + } + } + + if ($self->get_debug) { + my $count = scalar(@streaming_urls); + say STDERR ":: Found $count streaming URLs..."; + } + + # Try again with youtube-dl + if (!@streaming_urls or $info{status} =~ /fail|error/i) { + @streaming_urls = $self->_fallback_extract_urls($videoID); + } + + if ($self->get_prefer_mp4 or $self->get_prefer_av1) { + + my @video_urls; + my @audio_urls; + + require WWW::PipeViewer::Itags; + state $itags = WWW::PipeViewer::Itags::get_itags(); + + my %audio_itags; + @audio_itags{map { $_->{value} } @{$itags->{audio}}} = (); + + foreach my $url (@streaming_urls) { + + if (exists($audio_itags{$url->{itag}})) { + push @audio_urls, $url; + next; + } + + if ($url->{type} =~ /\bvideo\b/i) { + if ($url->{type} =~ /\bav[0-9]+\b/i) { # AV1 + if ($self->get_prefer_av1) { + push @video_urls, $url; + } + } + elsif ($self->get_prefer_mp4 and $url->{type} =~ /\bmp4\b/i) { + push @video_urls, $url; + } + } + else { + push @audio_urls, $url; + } + } + + if (@video_urls) { + @streaming_urls = (@video_urls, @audio_urls); + } + } + + # Filter out streams with `clen = 0`. + @streaming_urls = grep { defined($_->{clen}) ? ($_->{clen} > 0) : 1 } @streaming_urls; + + # Return the YouTube URL when there are no streaming URLs + if (!@streaming_urls) { + push @streaming_urls, + { + itag => 38, + type => "video/mp4", + url => "https://www.youtube.com/watch?v=$videoID", + }; + } + + if ($self->get_debug >= 2) { + require Data::Dump; + Data::Dump::pp(\%info) if ($self->get_debug >= 3); + Data::Dump::pp(\@streaming_urls); + Data::Dump::pp(\@caption_urls); + } + + return (\@streaming_urls, \@caption_urls, \%info); +} + +sub _request { + my ($self, $req) = @_; + + $self->{lwp} // $self->set_lwp_useragent(); + + my $res = $self->{lwp}->request($req); + + if ($res->is_success) { + return $res->decoded_content; + } + else { + warn 'Request error: ' . $res->status_line(); + } + + return; +} + +sub _prepare_request { + my ($self, $req, $length) = @_; + + $req->header('Content-Length' => $length) if ($length); + + if (defined $self->get_access_token) { + $req->header('Authorization' => $self->prepare_access_token); + } + + return 1; +} + +sub _save { + my ($self, $method, $uri, $content) = @_; + + require HTTP::Request; + my $req = HTTP::Request->new($method => $uri); + $req->content_type('application/json; charset=UTF-8'); + $self->_prepare_request($req, length($content)); + $req->content($content); + + $self->_request($req); +} + +sub post_as_json { + my ($self, $url, $ref) = @_; + my $json_str = $self->make_json_string($ref); + $self->_save('POST', $url, $json_str); +} + +sub next_page_with_token { + my ($self, $url, $token) = @_; + + if (not $url =~ s{[?&]continuation=\K([^&]+)}{$token}) { + $url = $self->_append_url_args($url, continuation => $token); + } + + my $res = $self->_get_results($url); + $res->{url} = $url; + return $res; +} + +sub next_page { + my ($self, $url, $token) = @_; + + if ($token) { + return $self->next_page_with_token($url, $token); + } + + if (not $url =~ s{[?&]page=\K(\d+)}{$1+1}e) { + $url = $self->_append_url_args($url, page => 2); + } + + my $res = $self->_get_results($url); + $res->{url} = $url; + 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'; + + # Create proxy_{exec,system} subroutines + foreach my $name ('exec', 'system', 'stdout') { + *{__PACKAGE__ . '::proxy_' . $name} = sub { + my ($self, @args) = @_; + + $self->{lwp} // $self->set_lwp_useragent(); + + local $ENV{http_proxy} = $self->{lwp}->proxy('http'); + local $ENV{https_proxy} = $self->{lwp}->proxy('https'); + + local $ENV{HTTP_PROXY} = $self->{lwp}->proxy('http'); + local $ENV{HTTPS_PROXY} = $self->{lwp}->proxy('https'); + + local $" = " "; + + $name eq 'exec' ? exec(@args) + : $name eq 'system' ? system(@args) + : $name eq 'stdout' ? qx(@args) + : (); + }; + } +} + +=head1 AUTHOR + +Trizen, C<< >> + +=head1 SEE ALSO + +https://developers.google.com/youtube/v3/docs/ + +=head1 LICENSE AND COPYRIGHT + +Copyright 2012-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of the the Artistic License (2.0). You may obtain a +copy of the full license at: + +L + +Any use, modification, and distribution of the Standard or Modified +Versions is governed by this Artistic License. By using, modifying or +distributing the Package, you accept this license. Do not use, modify, +or distribute the Package, if you do not accept this license. + +If your Modified Version has been derived from a Modified Version made +by someone other than you, you are nevertheless required to ensure that +your Modified Version complies with the requirements of this license. + +This license does not grant you the right to use any trademark, service +mark, tradename, or logo of the Copyright Holder. + +This license includes the non-exclusive, worldwide, free-of-charge +patent license to make, have made, use, offer to sell, sell, import and +otherwise transfer the Package with respect to any patent claims +licensable by the Copyright Holder that are necessarily infringed by the +Package. If you institute patent litigation (including a cross-claim or +counterclaim) against any party alleging that the Package constitutes +direct or contributory patent infringement, then this Artistic License +to you shall terminate on the date that such litigation is filed. + +Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER +AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. +THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY +YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR +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::PipeViewer + +__END__ diff --git a/lib/WWW/PipeViewer/Activities.pm b/lib/WWW/PipeViewer/Activities.pm new file mode 100644 index 0000000..252c69a --- /dev/null +++ b/lib/WWW/PipeViewer/Activities.pm @@ -0,0 +1,93 @@ +package WWW::PipeViewer::Activities; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::Activities - list of channel activity events that match the request criteria. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + my $activities = $obj->activities($channel_id); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _make_activities_url { + my ($self, %opts) = @_; + $self->_make_feed_url('activities', part => 'snippet,contentDetails', %opts); +} + +=head2 activities($channel_id) + +Get activities for channel ID. + +=cut + +sub activities { + my ($self, $channel_id) = @_; + + if ($channel_id eq 'mine') { + return $self->my_activities; + } + + if ($channel_id !~ /^UC/) { + $channel_id = $self->channel_id_from_username($channel_id) // $channel_id; + } + + $self->_get_results($self->_make_activities_url(channelId => $channel_id)); +} + +=head2 activities_from_username($username) + +Get activities for username. + +=cut + +sub activities_from_username { + my ($self, $username) = @_; + return $self->activities($username); +} + +=head2 my_activities() + +Get authenticated user's activities. + +=cut + +sub my_activities { + my ($self) = @_; + $self->get_access_token() // return; + $self->_get_results($self->_make_activities_url(mine => 'true')); +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::Activities + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::Activities diff --git a/lib/WWW/PipeViewer/Authentication.pm b/lib/WWW/PipeViewer/Authentication.pm new file mode 100644 index 0000000..c56769b --- /dev/null +++ b/lib/WWW/PipeViewer/Authentication.pm @@ -0,0 +1,216 @@ +package WWW::PipeViewer::Authentication; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::Authentication - OAuth login support. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $hash_ref = WWW::PipeViewer->oauth_login($code); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _get_token_oauth_url { + my ($self) = @_; + return $self->get_oauth_url() . 'token'; +} + +=head2 oauth_refresh_token() + +Refresh the access_token using the refresh_token. Returns a HASH ref with the `access_token` or undef. + +=cut + +sub oauth_refresh_token { + my ($self) = @_; + + my $json_data = $self->lwp_post( + $self->_get_token_oauth_url(), + [Content => $self->get_www_content_type, + client_id => $self->get_client_id() // return, + client_secret => $self->get_client_secret() // return, + refresh_token => $self->get_refresh_token() // return, + grant_type => 'refresh_token', + ] + ); + + return $self->parse_json_string($json_data); +} + +=head2 get_accounts_oauth_url() + +Creates an OAuth URL with the 'code' response type. (Google's authorization server) + +=cut + +sub get_accounts_oauth_url { + my ($self) = @_; + + my $url = $self->_append_url_args( + ($self->get_oauth_url() . 'auth'), + response_type => 'code', + client_id => $self->get_client_id() // return, + redirect_uri => $self->get_redirect_uri() // return, + scope => 'https://www.googleapis.com/auth/youtube.force-ssl', + access_type => 'offline', + ); + return $url; +} + +=head2 oauth_login($code) + +Returns a HASH ref with the access_token, refresh_token and some other info. + +The $code can be obtained by going to the URL returned by the C method. + +=cut + +sub oauth_login { + my ($self, $code) = @_; + + length($code) < 20 and return; + + my $json_data = $self->lwp_post( + $self->_get_token_oauth_url(), + [Content => $self->get_www_content_type, + client_id => $self->get_client_id() // return, + client_secret => $self->get_client_secret() // return, + redirect_uri => $self->get_redirect_uri() // return, + grant_type => 'authorization_code', + code => $code, + ] + ); + + return $self->parse_json_string($json_data); +} + +sub __AUTH_EOL__() { "\0\0\0" } + +=head2 load_authentication_tokens() + +Will try to load the access and refresh tokens from I. + +=cut + +sub load_authentication_tokens { + my ($self) = @_; + + if (defined $self->get_access_token and defined $self->get_refresh_token) { + return 1; + } + + my $file = $self->get_authentication_file() // return; + my $key = $self->get_key() // return; + + if (-f $file) { + local $/ = __AUTH_EOL__; + open my $fh, '<:raw', $file or return; + + my @tokens; + foreach my $i (0 .. 1) { + chomp(my $token = <$fh>); + $token =~ /\S/ || last; + push @tokens, $self->decode_token($token); + } + + $self->set_access_token($tokens[0]) // return; + $self->set_refresh_token($tokens[1]) // return; + + close $fh; + return 1; + } + + return; +} + +=head2 encode_token($token) + +Encode the token with the I and return it. + +=cut + +sub encode_token { + my ($self, $token) = @_; + + if (defined(my $key = $self->get_key)) { + require MIME::Base64; + return MIME::Base64::encode_base64($token ^ substr($key, -length($token))); + } + + return; +} + +=head2 decode_token($token) + +Decode the token with the I and return it. + +=cut + +sub decode_token { + my ($self, $token) = @_; + + if (defined(my $key = $self->get_key)) { + require MIME::Base64; + my $bin = MIME::Base64::decode_base64($token); + return $bin ^ substr($key, -length($bin)); + } + + return; +} + +=head2 save_authentication_tokens() + +Encode and save the access and refresh into the I. + +=cut + +sub save_authentication_tokens { + my ($self) = @_; + + my $file = $self->get_authentication_file() // return; + my $access_token = $self->get_access_token() // return; + my $refresh_token = $self->get_refresh_token() // return; + + if (open my $fh, '>:raw', $file) { + foreach my $token ($access_token, $refresh_token) { + print {$fh} $self->encode_token($token) . __AUTH_EOL__; + } + close $fh; + return 1; + } + + return; +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::Authentication + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::Authentication diff --git a/lib/WWW/PipeViewer/Channels.pm b/lib/WWW/PipeViewer/Channels.pm new file mode 100644 index 0000000..fd0ae4a --- /dev/null +++ b/lib/WWW/PipeViewer/Channels.pm @@ -0,0 +1,214 @@ +package WWW::PipeViewer::Channels; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::Channels - Channels interface. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + my $videos = $obj->channels_from_categoryID($category_id); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _make_channels_url { + my ($self, %opts) = @_; + return $self->_make_feed_url('channels', %opts); +} + +sub videos_from_channel_id { + my ($self, $channel_id) = @_; + return $self->_get_results($self->_make_feed_url("channels/$channel_id/videos")); +} + +sub videos_from_username { + my ($self, $channel_id) = @_; + return $self->_get_results($self->_make_feed_url("channels/$channel_id/videos")); +} + +=head2 popular_videos($channel_id) + +Get the most popular videos for a given channel ID. + +=cut + +sub popular_videos { + my ($self, $channel_id) = @_; + + if (not defined($channel_id)) { # trending 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')); +} + +=head2 channels_from_categoryID($category_id) + +Return the YouTube channels associated with the specified category. + +=head2 channels_info($channel_id) + +Return information for the comma-separated list of the YouTube channel ID(s). + +=head1 Channel details + +For all functions, C<$channels->{results}{items}> contains: + +=cut + +{ + no strict 'refs'; + + foreach my $method ( + { + key => 'categoryId', + name => 'channels_from_guide_category', + }, + { + key => 'id', + name => 'channels_info', + }, + { + key => 'forUsername', + name => 'channels_from_username', + }, + ) { + *{__PACKAGE__ . '::' . $method->{name}} = sub { + my ($self, $channel_id) = @_; + return $self->_get_results($self->_make_channels_url($method->{key} => $channel_id)); + }; + } + + foreach my $part (qw(id contentDetails statistics topicDetails)) { + *{__PACKAGE__ . '::' . 'channels_' . $part} = sub { + my ($self, $id) = @_; + return $self->_get_results($self->_make_channels_url(id => $id, part => $part)); + }; + } +} + +=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() + +Returns the channel ID of the current authenticated user. + +=cut + +sub my_channel_id { + my ($self) = @_; + + state $cache = {}; + + if (exists $cache->{id}) { + return $cache->{id}; + } + + $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) = @_; + + # 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 + return $username; +} + +=head2 channel_title_from_id($channel_id) + +Return the channel title for a given channel ID. + +=cut + +sub channel_title_from_id { + my ($self, $channel_id) = @_; + + if ($channel_id eq 'mine') { + $channel_id = $self->my_channel_id(); + } + + my $info = $self->channels_info($channel_id // return) // return; + + ( ref($info) eq 'HASH' + and ref($info->{results}) eq 'HASH' + and ref($info->{results}{items}) eq 'ARRAY' + and ref($info->{results}{items}[0]) eq 'HASH') + ? $info->{results}{items}[0]{snippet}{title} + : (); +} + +=head2 channels_contentDetails($channelID) + +=head2 channels_statistics($channelID); + +=head2 channels_topicDetails($channelID) + +=cut + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::Channels + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::Channels diff --git a/lib/WWW/PipeViewer/CommentThreads.pm b/lib/WWW/PipeViewer/CommentThreads.pm new file mode 100644 index 0000000..0336dcf --- /dev/null +++ b/lib/WWW/PipeViewer/CommentThreads.pm @@ -0,0 +1,98 @@ +package WWW::PipeViewer::CommentThreads; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::CommentThreads - Retrieve comments threads. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + my $videos = $obj->comments_from_video_id($video_id); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _make_commentThreads_url { + my ($self, %opts) = @_; + return + $self->_make_feed_url( + 'commentThreads', + pageToken => $self->page_token, + %opts + ); +} + +=head2 comments_from_videoID($videoID) + +Retrieve comments from a video ID. + +=cut + +sub comments_from_video_id { + my ($self, $video_id) = @_; + $self->_get_results( + $self->_make_feed_url("comments/$video_id", + sort_by => $self->get_comments_order, + ), + ); +} + +=head2 comment_to_video_id($comment, $videoID) + +Send a comment to a video ID. + +=cut + +sub comment_to_video_id { + my ($self, $comment, $video_id) = @_; + + my $url = $self->_simple_feeds_url('commentThreads', part => 'snippet'); + + my $hash = { + "snippet" => { + + "topLevelComment" => { + "snippet" => { + "textOriginal" => $comment, + } + }, + "videoId" => $video_id, + + #"channelId" => $channel_id, + }, + }; + + $self->post_as_json($url, $hash); +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::CommentThreads + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2015-2016 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::CommentThreads diff --git a/lib/WWW/PipeViewer/GetCaption.pm b/lib/WWW/PipeViewer/GetCaption.pm new file mode 100644 index 0000000..2b58150 --- /dev/null +++ b/lib/WWW/PipeViewer/GetCaption.pm @@ -0,0 +1,252 @@ +package WWW::PipeViewer::GetCaption; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::GetCaption - Save the YouTube closed captions as .srt files for a videoID. + +=head1 SYNOPSIS + + use WWW::PipeViewer::GetCaption; + + my $yv_cap = WWW::PipeViewer::GetCaption->new(%opts); + my $file = $yv_cap->save_caption($videoID); + +=head1 SUBROUTINES/METHODS + +=head2 new(%opts) + +Options: + +=over 4 + +=item captions => [] + +The captions data. + +=item captions_dir => "." + +Where to save the closed captions. + +=item languages => [qw(en es ro jp)] + +Preferred languages. First found is saved and returned. + +=back + +=cut + +sub new { + my ($class, %opts) = @_; + + my $self = bless {}, $class; + + $self->{captions_dir} = undef; + $self->{captions} = []; + $self->{auto_captions} = 0; + $self->{languages} = [qw(en es)]; + $self->{yv_obj} = undef; + + foreach my $key (keys %{$self}) { + $self->{$key} = delete $opts{$key} + if exists $opts{$key}; + } + + $self->{yv_obj} //= do { + require WWW::PipeViewer; + WWW::PipeViewer->new(cache_dir => $self->{captions_dir},); + }; + + foreach my $invalid_key (keys %opts) { + warn "Invalid key: '${invalid_key}'"; + } + + return $self; +} + +=head2 find_caption_data() + +Find a caption data, based on the preferred languages. + +=cut + +sub find_caption_data { + my ($self) = @_; + + my @found; + foreach my $caption (@{$self->{captions}}) { + if (defined $caption->{languageCode}) { + foreach my $i (0 .. $#{$self->{languages}}) { + my $lang = $self->{languages}[$i]; + if ($caption->{languageCode} =~ /^\Q$lang\E(?:\z|[_-])/i) { + + # Automatic Speech Recognition + my $auto = defined($caption->{kind}) && lc($caption->{kind}) eq 'asr'; + + # Check against auto-generated captions + if ($auto and not $self->{auto_captions}) { + next; + } + + # Fuzzy match or auto-generated caption + if (lc($caption->{languageCode}) ne lc($lang) or $auto) { + $found[$i + (($auto ? 2 : 1) * scalar(@{$self->{languages}}))] = $caption; + } + + # Perfect match + else { + $i == 0 and return $caption; + $found[$i] = $caption; + } + } + } + } + } + + foreach my $caption (@found) { + return $caption if defined($caption); + } + + return; +} + +=head2 sec2time(@seconds) + +Convert a list of seconds to .srt times. + +=cut + +sub sec2time { + my $self = shift; + + my @out; + foreach my $sec (map { sprintf '%.3f', $_ } @_) { + push @out, + sprintf('%02d:%02d:%02d,%03d', ($sec / 3600 % 24, $sec / 60 % 60, $sec % 60, substr($sec, index($sec, '.') + 1))); + } + + return @out; +} + +=head2 xml2srt($xml_string) + +Convert the XML data to SubRip format. + +=cut + +sub xml2srt { + my ($self, $xml) = @_; + + require WWW::PipeViewer::ParseXML; + my $hash = eval { WWW::PipeViewer::ParseXML::xml2hash($xml) } // return; + + my $sections; + if ( exists $hash->{transcript} + and ref($hash->{transcript}) eq 'ARRAY' + and ref($hash->{transcript}[0]) eq 'HASH' + and exists $hash->{transcript}[0]{text}) { + $sections = $hash->{transcript}[0]{text}; + } + else { + return; + } + + require HTML::Entities; + + my @text; + foreach my $i (0 .. $#{$sections}) { + my $line = $sections->[$i]; + + if (not defined($line->{'-dur'})) { + if (exists $sections->[$i + 1]) { + $line->{'-dur'} = $sections->[$i + 1]{'-start'} - $line->{'-start'}; + } + else { + $line->{'-dur'} = 10; + } + } + + my $start = $line->{'-start'}; + my $end = $start + $line->{'-dur'}; + + push @text, + join("\n", + $i + 1, + join(' --> ', $self->sec2time($start, $end)), + HTML::Entities::decode_entities($line->{'#text'} // '')); + } + + 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. + +=cut + +sub save_caption { + my ($self, $video_id) = @_; + + # Find one of the preferred languages + my $info = $self->find_caption_data() // return; + + require File::Spec; + my $filename = "${video_id}_$info->{languageCode}.srt"; + my $srt_file = File::Spec->catfile($self->{captions_dir} // File::Spec->tmpdir, $filename); + + # Return the srt file if it already exists + 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; + + # Write the SubRib data to the $srt_file + open(my $fh, '>:utf8', $srt_file) or return; + print {$fh} $srt, "\n"; + close $fh; + + # Return the .srt file path + return $srt_file; +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::GetCaption + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2012-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::GetCaption diff --git a/lib/WWW/PipeViewer/GuideCategories.pm b/lib/WWW/PipeViewer/GuideCategories.pm new file mode 100644 index 0000000..9bbf66c --- /dev/null +++ b/lib/WWW/PipeViewer/GuideCategories.pm @@ -0,0 +1,85 @@ +package WWW::PipeViewer::GuideCategories; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::GuideCategories - Categories interface. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + my $videos = $obj->youtube_categories('US'); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _make_guideCategories_url { + my ($self, %opts) = @_; + + if (not exists $opts{id}) { + $opts{region} //= $self->get_region; + } + + $self->_make_feed_url('guideCategories', %opts); +} + +=head2 guide_categories(;$region_id) + +Return guide categories for a specific region ID. + +=head2 guide_categories_info($category_id) + +Return info for a list of comma-separated category IDs. + +=cut + +{ + no strict 'refs'; + + foreach my $method ( + { + key => 'id', + name => 'guide_categories_info', + }, + { + key => 'region', + name => 'guide_categories', + }, + ) { + *{__PACKAGE__ . '::' . $method->{name}} = sub { + my ($self, $id) = @_; + return $self->_get_results($self->_make_guideCategories_url($method->{key} => $id // return)); + }; + } +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::GuideCategories + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::GuideCategories diff --git a/lib/WWW/PipeViewer/Itags.pm b/lib/WWW/PipeViewer/Itags.pm new file mode 100644 index 0000000..b5ff171 --- /dev/null +++ b/lib/WWW/PipeViewer/Itags.pm @@ -0,0 +1,299 @@ +package WWW::PipeViewer::Itags; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::Itags - Get the YouTube itags. + +=head1 SYNOPSIS + + use WWW::PipeViewer::Itags; + + my $yv_itags = WWW::PipeViewer::Itags->new(); + + my $itags = $yv_itags->get_itags(); + my $res = $yv_itags->get_resolutions(); + +=head1 SUBROUTINES/METHODS + +=head2 new() + +Return the blessed object. + +=cut + +sub new { + my ($class) = @_; + bless {}, $class; +} + +=head2 get_itags() + +Get a HASH ref with the YouTube itags. {resolution => [itags]}. + +Reference: http://en.wikipedia.org/wiki/YouTube#Quality_and_formats + +=cut + +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) + ], + + '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) + ], + + '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) + ], + + '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) + ], + + '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) + ], + + '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) + ], + + '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) + ], + + '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) + ], + + '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) + ], + + '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) + ], + }; +} + +=head2 get_resolutions() + +Get an ARRAY ref with the supported resolutions ordered from highest to lowest. + +=cut + +sub get_resolutions { + my ($self) = @_; + + state $itags = $self->get_itags(); + return [ + grep { exists $itags->{$_} } + qw( + best + 2160 + 1440 + 1080 + 720 + 480 + 360 + 240 + 144 + audio + ) + ]; +} + +sub _find_streaming_url { + my ($self, %args) = @_; + + my $stream = $args{stream} // return; + my $resolution = $args{resolution} // return; + + foreach my $itag (@{$args{itags}->{$resolution}}) { + + next if not exists $stream->{$itag->{value}}; + + my $entry = $stream->{$itag->{value}}; + + if (defined($entry->{fps}) and $entry->{fps} >= 50) { + $args{hfr} || next; # skip high frame rate (HFR) videos + } + + if ($itag->{format} eq 'av1') { + $args{ignore_av1} && next; # ignore videos in AV1 format + } + + if ($itag->{dash}) { + + $args{dash} || next; + + my $video_info = $stream->{$itag->{value}}; + my $audio_info = $self->_find_streaming_url(%args, resolution => 'audio', dash => 0); + + if (defined($audio_info)) { + $video_info->{__AUDIO__} = $audio_info; + return $video_info; + } + + next; + } + + if ($resolution eq 'audio' and not $args{dash_mp4_audio}) { + if ($itag->{format} eq 'm4a') { + next; # skip m4a audio URLs + } + } + + # Ignore segmented DASH URLs (they load pretty slow in mpv) + if (not $args{dash_segmented}) { + next if ($entry->{url} =~ m{/api/manifest/dash/}); + } + + return $entry; + } + + return; +} + +=head2 find_streaming_url(%options) + +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 + ) + +=cut + +sub find_streaming_url { + my ($self, %args) = @_; + + my $urls_array = $args{urls}; + my $resolution = $args{resolution}; + + state $itags = $self->get_itags(); + + if (defined($resolution) and $resolution =~ /^([0-9]+)/) { + $resolution = $1; + } + + my %stream; + foreach my $info_ref (@{$urls_array}) { + if (exists $info_ref->{itag} and exists $info_ref->{url}) { + $stream{$info_ref->{itag}} = $info_ref; + } + } + + $args{stream} = \%stream; + $args{itags} = $itags; + $args{resolution} = $resolution; + + my ($streaming, $found_resolution); + + # Try to find the wanted resolution + if (defined($resolution) and exists $itags->{$resolution}) { + $streaming = $self->_find_streaming_url(%args); + $found_resolution = $resolution; + } + + # Otherwise, find the best resolution available + if (not defined $streaming) { + + state $resolutions = $self->get_resolutions(); + + foreach my $res (@{$resolutions}) { + + $streaming = $self->_find_streaming_url(%args, resolution => $res); + + if (defined($streaming)) { + $found_resolution = $res; + last; + } + } + } + + wantarray ? ($streaming, $found_resolution) : $streaming; +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::Itags + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2012-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::Itags diff --git a/lib/WWW/PipeViewer/ParseJSON.pm b/lib/WWW/PipeViewer/ParseJSON.pm new file mode 100644 index 0000000..a69b37d --- /dev/null +++ b/lib/WWW/PipeViewer/ParseJSON.pm @@ -0,0 +1,88 @@ +package WWW::PipeViewer::ParseJSON; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::ParseJSON - Parse JSON content. + +=head1 SYNOPSIS + + use WWW::PipeViewer::ParseJSON; + my $obj = WWW::PipeViewer::ParseJSON->new(%opts); + +=head1 SUBROUTINES/METHODS + +=cut + +=head2 parse_json_string($json_string) + +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) = @_; + + if (not defined($json) or $json eq '') { + return {}; + } + + require JSON; + my $hash = eval { JSON::decode_json($json) }; + return $@ ? do { warn "[JSON]: $@\n"; {} } : $hash; +} + +=head2 make_json_string($ref) + +Create a JSON string from a HASH or ARRAY ref. + +=cut + +sub make_json_string { + my ($self, $ref) = @_; + + require JSON; + my $str = eval { JSON::encode_json($ref) }; + return $@ ? do { warn "[JSON]: $@\n"; '' } : $str; +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::ParseJSON + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::ParseJSON diff --git a/lib/WWW/PipeViewer/ParseXML.pm b/lib/WWW/PipeViewer/ParseXML.pm new file mode 100644 index 0000000..fe42bd3 --- /dev/null +++ b/lib/WWW/PipeViewer/ParseXML.pm @@ -0,0 +1,311 @@ +package WWW::PipeViewer::ParseXML; + +use utf8; +use 5.014; +use warnings; + +=encoding utf8 + +=head1 NAME + +WWW::PipeViewer::ParseXML - Convert XML to a HASH ref structure. + +=head1 SYNOPSIS + +Parse XML content and return an HASH ref structure. + +Usage: + + use WWW::PipeViewer::ParseXML; + my $hash_ref = WWW::PipeViewer::ParseXML::xml2hash($xml_string); + +=head1 SUBROUTINES/METHODS + +=head2 xml2hash($xml_string) + +Parse XML and return an HASH ref. + +=cut + +sub xml2hash { + my $xml = shift() // return; + + $xml = "$xml"; # copy the string + + my $xml_ref = {}; + + my %args = ( + attr => '-', + text => '#text', + empty => q{}, + @_ + ); + + my %ctags; + my $ref = $xml_ref; + + state $inv_chars = q{!"#$@%&'()*+,/;\\<=>?\]\[^`{|}~}; + state $valid_tag = qr{[^\-.\s0-9$inv_chars][^$inv_chars\s]*}; + + { + if ( + $xml =~ m{\G< \s* + ($valid_tag) \s* + ((?>$valid_tag\s*=\s*(?>".*?"|'.*?')|\s+)+)? \s* + (/)?\s*> \s* + }gcsxo + ) { + + my ($tag, $attrs, $closed) = ($1, $2, $3); + + if (defined $attrs) { + push @{$ctags{$tag}}, $ref; + + $ref = + ref $ref eq 'HASH' + ? ref $ref->{$tag} + ? $ref->{$tag} + : ( + defined $ref->{$tag} + ? ($ref->{$tag} = [$ref->{$tag}]) + : ($ref->{$tag} //= []) + ) + : ref $ref eq 'ARRAY' ? ref $ref->[-1]{$tag} + ? $ref->[-1]{$tag} + : ( + defined $ref->[-1]{$tag} + ? ($ref->[-1]{$tag} = [$ref->[-1]{$tag}]) + : ($ref->[-1]{$tag} //= []) + ) + : []; + + ++$#{$ref} if ref $ref eq 'ARRAY'; + + while ( + $attrs =~ m{\G + ($valid_tag) \s*=\s* + (?> + "(.*?)" + | + '(.*?)' + ) \s* + }gsxo + ) { + my ($key, $value) = ($1, $+); + $key = join(q{}, $args{attr}, $key); + if (ref $ref eq 'ARRAY') { + $ref->[-1]{$key} = _decode_entities($value); + } + elsif (ref $ref eq 'HASH') { + $ref->{$key} = $value; + } + } + + if (defined $closed) { + $ref = pop @{$ctags{$tag}}; + } + + if ($xml =~ m{\G<\s*/\s*\Q$tag\E\s*>\s*}gc) { + $ref = pop @{$ctags{$tag}}; + } + elsif ($xml =~ m{\G([^<]+)(?=<)}gsc) { + if (ref $ref eq 'ARRAY') { + $ref->[-1]{$args{text}} .= _decode_entities($1); + $ref = pop @{$ctags{$tag}}; + } + elsif (ref $ref eq 'HASH') { + $ref->{$args{text}} .= $1; + $ref = pop @{$ctags{$tag}}; + } + } + } + elsif (defined $closed) { + if (ref $ref eq 'ARRAY') { + if (exists $ref->[-1]{$tag}) { + if (ref $ref->[-1]{$tag} ne 'ARRAY') { + $ref->[-1]{$tag} = [$ref->[-1]{$tag}]; + } + push @{$ref->[-1]{$tag}}, $args{empty}; + } + else { + $ref->[-1]{$tag} = $args{empty}; + } + } + } + else { + if ($xml =~ /\G(?=<(?!!))/) { + push @{$ctags{$tag}}, $ref; + + $ref = + ref $ref eq 'HASH' + ? ref $ref->{$tag} + ? $ref->{$tag} + : ( + defined $ref->{$tag} + ? ($ref->{$tag} = [$ref->{$tag}]) + : ($ref->{$tag} //= []) + ) + : ref $ref eq 'ARRAY' ? ref $ref->[-1]{$tag} + ? $ref->[-1]{$tag} + : ( + defined $ref->[-1]{$tag} + ? ($ref->[-1]{$tag} = [$ref->[-1]{$tag}]) + : ($ref->[-1]{$tag} //= []) + ) + : []; + + ++$#{$ref} if ref $ref eq 'ARRAY'; + redo; + } + elsif ($xml =~ /\G\s*/gcs or $xml =~ /\G([^<]+)(?=<)/gsc) { + my ($text) = $1; + + if ($xml =~ m{\G<\s*/\s*\Q$tag\E\s*>\s*}gc) { + if (ref $ref eq 'ARRAY') { + if (exists $ref->[-1]{$tag}) { + if (ref $ref->[-1]{$tag} ne 'ARRAY') { + $ref->[-1]{$tag} = [$ref->[-1]{$tag}]; + } + push @{$ref->[-1]{$tag}}, $text; + } + else { + $ref->[-1]{$tag} .= _decode_entities($text); + } + } + elsif (ref $ref eq 'HASH') { + $ref->{$tag} .= $text; + } + } + else { + push @{$ctags{$tag}}, $ref; + + $ref = + ref $ref eq 'HASH' + ? ref $ref->{$tag} + ? $ref->{$tag} + : ( + defined $ref->{$tag} + ? ($ref->{$tag} = [$ref->{$tag}]) + : ($ref->{$tag} //= []) + ) + : ref $ref eq 'ARRAY' ? ref $ref->[-1]{$tag} + ? $ref->[-1]{$tag} + : ( + defined $ref->[-1]{$tag} + ? ($ref->[-1]{$tag} = [$ref->[-1]{$tag}]) + : ($ref->[-1]{$tag} //= []) + ) + : []; + + ++$#{$ref} if ref $ref eq 'ARRAY'; + + if (ref $ref eq 'ARRAY') { + if (exists $ref->[-1]{$tag}) { + if (ref $ref->[-1]{$tag} ne 'ARRAY') { + $ref->[-1] = [$ref->[-1]{$tag}]; + } + push @{$ref->[-1]}, {$args{text} => $text}; + } + else { + $ref->[-1]{$args{text}} .= $text; + } + } + elsif (ref $ref eq 'HASH') { + $ref->{$tag} .= $text; + } + } + } + } + + if ($xml =~ m{\G<\s*/\s*\Q$tag\E\s*>\s*}gc) { + ## tag closed - ok + } + + redo; + } + elsif ($xml =~ m{\G<\s*/\s*($valid_tag)\s*>\s*}gco) { + if (exists $ctags{$1} and @{$ctags{$1}}) { + $ref = pop @{$ctags{$1}}; + } + redo; + } + elsif ($xml =~ /\G\s*/gcs or $xml =~ m{\G([^<]+)(?=<)}gsc) { + if (ref $ref eq 'ARRAY') { + $ref->[-1]{$args{text}} .= $1; + } + elsif (ref $ref eq 'HASH') { + $ref->{$args{text}} .= $1; + } + redo; + } + elsif ($xml =~ /\G<\?/gc) { + $xml =~ /\G.*?\?>\s*/gcs or die "Invalid XML!"; + redo; + } + elsif ($xml =~ /\G\s*/gcs or die "Comment not closed!"; + redo; + } + elsif ($xml =~ /\G$valid_tag|\s+|".*?"|'.*?')*\[.*?\]>\s*/sgco + or $xml =~ /\G.*?>\s*/sgc + or die "DOCTYPE not closed!"; + redo; + } + elsif ($xml =~ /\G\z/gc) { + ## ok + } + elsif ($xml =~ /\G\s+/gc) { + redo; + } + else { + die "Syntax error near: --> ", [split(/\n/, substr($xml, pos(), 2**6))]->[0], " <--\n"; + } + } + + return $xml_ref; +} + +{ + my %entities = ( + 'amp' => '&', + 'quot' => '"', + 'apos' => "'", + 'gt' => '>', + 'lt' => '<', + ); + + state $ent_re = do { + local $" = '|'; + qr/&(@{[keys %entities]});/; + }; + + sub _decode_entities { + $_[0] =~ s/$ent_re/$entities{$1}/gor; + } +} + +=head1 AUTHOR + +Trizen, C<< >> + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::ParseXML + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2012-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::ParseXML diff --git a/lib/WWW/PipeViewer/PlaylistItems.pm b/lib/WWW/PipeViewer/PlaylistItems.pm new file mode 100644 index 0000000..5a70e9f --- /dev/null +++ b/lib/WWW/PipeViewer/PlaylistItems.pm @@ -0,0 +1,146 @@ +package WWW::PipeViewer::PlaylistItems; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::PlaylistItems - Manage playlist entries. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + my $videos = $obj->videos_from_playlistID($playlist_id); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _make_playlistItems_url { + my ($self, %opts) = @_; + return + $self->_make_feed_url( + 'playlistItems', + pageToken => $self->page_token, + %opts + ); +} + +=head2 add_video_to_playlist($playlistID, $videoID; $position=1) + +Add a video to given playlist ID, at position 1 (by default) + +=cut + +sub add_video_to_playlist { + my ($self, $playlist_id, $video_id, $position) = @_; + + $self->get_access_token() // return; + + $playlist_id // return; + $video_id // return; + $position //= 0; + + my $hash = { + "snippet" => { + "playlistId" => $playlist_id, + "resourceId" => { + "videoId" => $video_id, + "kind" => "youtube#video" + }, + "position" => $position, + } + }; + + my $url = $self->_make_playlistItems_url(pageToken => undef); + $self->post_as_json($url, $hash); +} + +=head2 favorite_video($videoID) + +Favorite a video. Returns true on success. + +=cut + +sub favorite_video { + my ($self, $video_id) = @_; + $video_id // return; + $self->get_access_token() // return; + my $playlist_id = $self->get_playlist_id('favorites', mine => 'true') // return; + $self->add_video_to_playlist($playlist_id, $video_id); +} + +=head2 videos_from_playlist_id($playlist_id) + +Get videos from a specific playlistID. + +=cut + +sub videos_from_playlist_id { + my ($self, $id) = @_; + $self->_get_results($self->_make_feed_url("playlists/$id")); +} + +=head2 favorites($channel_id) + +=head2 uploads($channel_id) + +=head2 likes($channel_id) + +Get the favorites, uploads and likes for a given channel ID. + +=cut + +=head2 favorites_from_username($username) + +=head2 uploads_from_username($username) + +=head2 likes_from_username($username) + +Get the favorites, uploads and likes for a given YouTube username. + +=cut + +{ + no strict 'refs'; + foreach my $name (qw(favorites uploads likes)) { + + *{__PACKAGE__ . '::' . $name . '_from_username'} = sub { + my ($self, $username) = @_; + $self->videos_from_username($username); + }; + + *{__PACKAGE__ . '::' . $name} = sub { + my ($self, $channel_id) = @_; + $self->videos_from_channel_id($channel_id); + }; + } +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::PlaylistItems + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::PlaylistItems diff --git a/lib/WWW/PipeViewer/Playlists.pm b/lib/WWW/PipeViewer/Playlists.pm new file mode 100644 index 0000000..28c861f --- /dev/null +++ b/lib/WWW/PipeViewer/Playlists.pm @@ -0,0 +1,116 @@ +package WWW::PipeViewer::Playlists; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::Playlists - YouTube playlists related mehods. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + my $info = $obj->playlist_from_id($playlist_id); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _make_playlists_url { + my ($self, %opts) = @_; + + if (not exists $opts{'part'}) { + $opts{'part'} = 'snippet,contentDetails'; + } + + $self->_make_feed_url( + 'playlists', + %opts, + ); +} + +sub get_playlist_id { + my ($self, $playlist_name, %fields) = @_; + + my $url = $self->_simple_feeds_url('channels', qw(part contentDetails), %fields); + my $res = $self->_get_results($url); + + ref($res->{results}{items}) eq 'ARRAY' || return; + @{$res->{results}{items}} || return; + + return $res->{results}{items}[0]{contentDetails}{relatedPlaylists}{$playlist_name}; +} + +=head2 playlist_from_id($playlist_id) + +Return info for one or more playlists. +PlaylistIDs can be separated by commas. + +=cut + +sub playlist_from_id { + my ($self, $id, $part) = @_; + $self->_get_results($self->_make_playlists_url(id => $id, part => ($part // 'snippet'))); +} + +=head2 playlists($channel_id) + +Get and return playlists from a channel ID. + +=cut + +sub playlists { + my ($self, $channel_id) = @_; + $self->_get_results($self->_make_feed_url("channels/playlists/$channel_id")); +} + +=head2 playlists_from_username($username) + +Get and return the playlists created for a given username. + +=cut + +sub playlists_from_username { + my ($self, $username) = @_; + $self->playlists($username); +} + +=head2 my_playlists() + +Get and return your playlists. + +=cut + +sub my_playlists { + my ($self) = @_; + $self->get_access_token() // return; + $self->_get_results($self->_make_playlists_url(mine => 'true')); +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::Playlists + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::Playlists diff --git a/lib/WWW/PipeViewer/RegularExpressions.pm b/lib/WWW/PipeViewer/RegularExpressions.pm new file mode 100644 index 0000000..eae9e7d --- /dev/null +++ b/lib/WWW/PipeViewer/RegularExpressions.pm @@ -0,0 +1,89 @@ +package WWW::PipeViewer::RegularExpressions; + +use utf8; +use 5.014; +use warnings; + +require Exporter; +our @ISA = qw(Exporter); + +=head1 NAME + +WWW::PipeViewer::RegularExpressions - Various utils. + +=head1 SYNOPSIS + + use WWW::PipeViewer::RegularExpressions; + use WWW::PipeViewer::RegularExpressions ($get_video_id_re); + +=cut + +my $opt_begin_chars = q{:;=}; # stdin option valid begin chars + +# Options +our $range_num_re = qr{^([0-9]{1,3}+)(?>-|\.\.)([0-9]{1,3}+)?\z}; +our $digit_or_equal_re = qr/(?(?=[1-9])|=)/; +our $non_digit_or_opt_re = qr{^(?!$range_num_re)(?>[0-9]{1,3}[^0-9]|[0-9]{4}|[^0-9$opt_begin_chars])}; + +# Generic name +my $generic_name_re = qr/[a-zA-Z0-9_.\-]{11,64}/; +our $valid_channel_id_re = qr{^(?:.*/channel/)?(?(?:\w+(?:[-.]++\w++)*|$generic_name_re))(?:/.*)?\z}; + +our $get_channel_videos_id_re = qr{^.*/channel/(?(?:\w+(?:[-.]++\w++)*|$generic_name_re))}; +our $get_channel_playlists_id_re = qr{$get_channel_videos_id_re/playlists}; + +our $get_username_videos_re = qr{^.*/user/(?[-.\w]+)}; +our $get_username_playlists_re = qr{$get_username_videos_re/playlists}; + +# Video ID +my $video_id_re = qr/[0-9A-Za-z_\-]{11}/; +our $valid_video_id_re = qr{^$video_id_re\z}; +our $get_video_id_re = qr{(?:%3F|\b)(?>v|embed|youtu(?:\\)?[.]be)(?>(?:\\)?[=/]|%3D)(?$video_id_re)}; + +# Playlist ID +our $valid_playlist_id_re = qr{^$generic_name_re\z}; +our $get_playlist_id_re = qr{(?:(?:(?>playlist\?list|view_play_list\?p|list)=)|\w#p/c/)(?$generic_name_re)\b}; + +our $valid_opt_re = qr{^[$opt_begin_chars]([A-Za-z]++(?:-[A-Za-z]++)?(?>${digit_or_equal_re}.*)?)$}; + +our @EXPORT = qw( + $range_num_re + $digit_or_equal_re + $non_digit_or_opt_re + $valid_channel_id_re + $valid_video_id_re + $get_video_id_re + $valid_playlist_id_re + $get_playlist_id_re + $valid_opt_re + $get_channel_videos_id_re + $get_channel_playlists_id_re + $get_username_videos_re + $get_username_playlists_re + ); + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::RegularExpressions + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2012-2013 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::RegularExpressions diff --git a/lib/WWW/PipeViewer/Search.pm b/lib/WWW/PipeViewer/Search.pm new file mode 100644 index 0000000..ce662fa --- /dev/null +++ b/lib/WWW/PipeViewer/Search.pm @@ -0,0 +1,498 @@ +package WWW::PipeViewer::Search; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::Search - Search for stuff on YouTube + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + $obj->search_videos(@keywords); + +=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 _view_count_text_to_int { + my ($text) = @_; + + if ($text =~ /([\d,.]+)/) { + my $v = $1; + $v =~ tr/.,//d; + return $v; + } + + return 0; +} + +sub _thumbnail_quality { + my ($width, $height) = @_; + + $width // return 'medium'; + $height // return 'medium'; + + if ($width == 1280 and $height == 720) { + return "maxres"; + } + + if ($width == 640 and $height == 480) { + return "sddefault"; + } + + if ($width == 480 and $height == 360) { + return 'high'; + } + + if ($width == 320 and $height == 180) { + return 'medium'; + } + + if ($width == 120 and $height == 90) { + return 'default'; + } + + return 'medium'; +} + +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 { $header->{avatar}{thumbnails}[0]{url} } + // eval { $info->{heroImage}{collageHeroImageRenderer}{leftThumbnail}{thumbnails}[0]{url} }; + + $mix{author} = eval { $header->{title}{runs}[0]{text} } // "YouTube"; + $mix{authorId} = eval { $header->{titleNavigationEndpoint}{browseEndpoint}{browseId} } // "youtube"; + + return \%mix; +} + +sub _extract_search_entry { + my ($self, $data, %args) = @_; + + # Album + if ($args{type} eq 'all' and exists $data->{horizontalCardListRenderer}) { # TODO + return; + } + + # Video + if (exists $data->{compactVideoRenderer}) { + + my %video; + + my $info = $data->{compactVideoRenderer}; + + $video{title} = + eval { $info->{title}{runs}[0]{text} } // eval { $info->{title}{accessibility}{accessibilityData}{label} } // return; + $video{videoId} = eval { $info->{navigationEndpoint}{watchEndpoint}{videoId} } // $info->{videoId} // return; + $video{author} = eval { $info->{longBylineText}{runs}[0]{text} } // eval { $info->{shortBylineText}{runs}[0]{text} }; + $video{authorId} = $info->{channelId}; + $video{publishedText} = eval { $info->{publishedTimeText}{runs}[0]{text} }; + $video{viewCountText} = eval { $info->{shortViewCountText}{runs}[0]{text} }; + $video{videoThumbnails} = eval { + [ + map { + my %thumb = %$_; + $thumb{quality} = _thumbnail_quality($thumb{width}, $thumb{height}); + \%thumb; + } @{$info->{thumbnail}{thumbnails}} + ] + }; + + # FIXME: this is not the video description + $video{description} = eval { $info->{title}{accessibility}{accessibilityData}{label} }; + + my $time = eval { $info->{thumbnailOverlays}[0]{thumbnailOverlayTimeStatusRenderer}{text}{runs}[0]{text} }; + + if (defined($time)) { + $video{lengthSeconds} = eval { _time_to_seconds($time) }; + } + + $video{title} = eval { $info->{title}{runs}[0]{text} }; + + my $viewCountText = eval { $info->{viewCountText}{runs}[0]{text} }; + + if (defined($viewCountText)) { + $video{viewCount} = _view_count_text_to_int($viewCountText); + } + + return \%video; + } + + return; +} + +sub _extract_search_results { + my ($self, $data, %args) = @_; + + eval { ref($data->{contents}{sectionListRenderer}{contents}) eq 'ARRAY' } or return; + + my @results; + + foreach my $entry (@{$data->{contents}{sectionListRenderer}{contents}}) { + + # 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); + } + } + + # Search results + if (exists $entry->{itemSectionRenderer}) { + + eval { ref($entry->{itemSectionRenderer}{contents}) eq 'ARRAY' } || next; + + foreach my $entry (@{$entry->{itemSectionRenderer}{contents}}) { + + my $search_entry = $self->_extract_search_entry($entry, %args); + + if (defined($search_entry)) { + + #use Data::Dump qw(pp); + #pp $search_entry; + push @results, $search_entry; + } + + } + } + + # Continuation page + if (exists $entry->{continuationItemRenderer}) { # TODO + ## ... + } + } + + return @results; +} + +sub _youtube_search { + my ($self, %args) = @_; + + my $content = $self->lwp_get($self->get_m_youtube_url . "/results?search_query=$args{q}"); + + if ($content =~ m{
}is) { + my $json = $1; + my $hash = $self->parse_utf8_json_string($json); + return $self->_extract_search_results($hash, %args); + } + + return; +} + +sub _make_search_url { + my ($self, %opts) = @_; + + my @features; + + if (defined(my $vd = $self->get_videoDefinition)) { + if ($vd eq 'high') { + push @features, 'hd'; + } + } + + if (defined(my $vc = $self->get_videoCaption)) { + if ($vc eq 'true' or $vc eq '1') { + push @features, 'subtitles'; + } + } + + if (defined(my $vd = $self->get_videoDimension)) { + if ($vd eq '3d') { + push @features, '3d'; + } + } + + if (defined(my $license = $self->get_videoLicense)) { + if ($license eq 'creative_commons') { + push @features, 'creative_commons'; + } + } + + return $self->_make_feed_url( + 'search', + + region => $self->get_region, + sort_by => $self->get_order, + date => $self->get_date, + page => $self->page_token, + duration => $self->get_videoDuration, + + (@features ? (features => join(',', @features)) : ()), + + %opts, + ); +} + +=head2 search_for($types,$keywords;\%args) + +Search for a list of types (comma-separated). + +=cut + +sub search_for { + my ($self, $type, $keywords, $args) = @_; + + if (ref($args) ne 'HASH') { + $args = {}; + } + + $keywords //= []; + + if (ref($keywords) ne 'ARRAY') { + $keywords = [split ' ', $keywords]; + } + + $keywords = $self->escape_string(join(' ', @{$keywords})); + + # 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,); + return $self->_get_results($url); + } + + my $url = $self->_make_search_url( + type => $type, + q => $keywords, + %$args, + ); + + return + scalar { + url => $url, + results => [$self->_youtube_search(q => $keywords, type => $type, %$args)], + }; + + return $self->_get_results($url); +} + +{ + no strict 'refs'; + + foreach my $pair ( + { + name => 'videos', + type => 'video', + }, + { + name => 'playlists', + type => 'playlist', + }, + { + name => 'channels', + type => 'channel', + }, + { + name => 'all', + type => 'all', + } + ) { + *{__PACKAGE__ . '::' . "search_$pair->{name}"} = sub { + my $self = shift; + $self->search_for($pair->{type}, @_); + }; + } +} + +=head2 search_videos($keywords;\%args) + +Search and return the found video results. + +=cut + +=head2 search_playlists($keywords;\%args) + +Search and return the found playlists. + +=cut + +=head2 search_channels($keywords;\%args) + +Search and return the found channels. + +=cut + +=head2 search_all($keywords;\%args) + +Search and return the results. + +=cut + +=head2 related_to_videoID($id) + +Retrieves a list of videos that are related to the video +that the parameter value identifies. The parameter value must +be set to a YouTube video ID. + +=cut + +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 $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) { + + my $info = $entry->{compactVideoRenderer} // next; + my $title = $info->{title}{simpleText} // next; + + my $viewCount = 0; + + if (($info->{viewCountText}{simpleText} // '') =~ /^([\d,]+) views/) { + $viewCount = ($1 =~ tr/,//dr); + } + elsif (($info->{viewCountText}{simpleText} // '') =~ /Recommended for you/i) { + next; # filter out recommended videos from related videos + } + + my $lengthSeconds = 0; + + if (($info->{lengthText}{simpleText} // '') =~ /([\d:]+)/) { + my $time = $1; + my @fields = split(/:/, $time); + + my $seconds = pop(@fields) // 0; + my $minutes = pop(@fields) // 0; + my $hours = pop(@fields) // 0; + + $lengthSeconds = 3600 * $hours + 60 * $minutes + $seconds; + } + + my $published = 0; + if (exists $info->{publishedTimeText} and $info->{publishedTimeText}{simpleText} =~ /(\d+)\s+(\w+)\s+ago/) { + + my $quantity = $1; + my $period = $2; + + $period =~ s/s\z//; # make it singural + + my %table = ( + year => 31556952, # seconds in a year + month => 2629743.83, # seconds in a month + week => 604800, # seconds in a week + day => 86400, # seconds in a day + hour => 3600, # seconds in a hour + minute => 60, # seconds in a minute + second => 1, # seconds in a second + ); + + if (exists $table{$period}) { + $published = int(time - $quantity * $table{$period}); + } + else { + warn "BUG: cannot parse: <<$quantity $period>>"; + } + } + + push @results, { + type => "video", + title => $title, + videoId => $info->{videoId}, + author => $info->{longBylineText}{runs}[0]{text}, + authorId => $info->{longBylineText}{runs}[0]{navigationEndpoint}{browseEndpoint}{browseId}, + + #authorUrl => $info->{longBylineText}{runs}[0]{navigationEndpoint}{browseEndpoint}{browseId}, + + description => $info->{accessibility}{accessibilityData}{label}, + descriptionHtml => undef, + viewCount => $viewCount, + published => $published, + publishedText => $info->{publishedTimeText}{simpleText}, + lengthSeconds => $lengthSeconds, + liveNow => ($lengthSeconds == 0), # maybe it's live if lengthSeconds == 0? + paid => 0, + premium => 0, + + videoThumbnails => [ + map { + scalar { + quality => 'medium', + url => $_->{url}, + width => $_->{width}, + height => $_->{height}, + } + } @{$info->{thumbnail}{thumbnails}} + ], + }; + } + + return + scalar { + url => undef, + results => \@results, + }; +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::Search + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::Search diff --git a/lib/WWW/PipeViewer/Subscriptions.pm b/lib/WWW/PipeViewer/Subscriptions.pm new file mode 100644 index 0000000..4038e3c --- /dev/null +++ b/lib/WWW/PipeViewer/Subscriptions.pm @@ -0,0 +1,272 @@ +package WWW::PipeViewer::Subscriptions; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::Subscriptions - Subscriptions handler. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + my $videos = $obj->subscriptions_from_channelID($channel_id); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _make_subscriptions_url { + my ($self, %opts) = @_; + return $self->_make_feed_url('subscriptions', %opts); +} + +=head2 subscribe_channel($channel_id) + +Subscribe to an YouTube channel. + +=cut + +sub subscribe_channel { + my ($self, $channel_id) = @_; + + my $resource = { + snippet => { + resourceId => { + kind => 'youtube#channel', + channelId => $channel_id, + } + } + }; + + my $url = $self->_simple_feeds_url('subscriptions', part => 'snippet'); + return $self->post_as_json($url, $resource); +} + +=head2 subscribe_channel_from_username($username) + +Subscribe to an YouTube channel via username. + +=cut + +sub subscribe_channel_from_username { + my ($self, $username) = @_; + $self->subscribe_channel($self->channel_id_from_username($username) // $username); +} + +=head2 subscriptions(;$channel_id) + +Retrieve the subscriptions for a channel ID or for the authenticated user. + +=cut + +sub subscriptions { + my ($self, $channel_id) = @_; + $self->_get_results( + $self->_make_subscriptions_url( + order => $self->get_subscriptions_order, + part => 'snippet', + ( + ($channel_id and $channel_id ne 'mine') + ? (channelId => $channel_id) + : do { $self->get_access_token() // return; (mine => 'true') } + ), + ) + ); +} + +=head2 subscriptions_from_username($username) + +Retrieve subscriptions for a given YouTube username. + +=cut + +sub subscriptions_from_username { + my ($self, $username) = @_; + $self->subscriptions($self->channel_id_from_username($username) // $username); +} + +=head2 subscription_videos(;$channel_id) + +Retrieve the video subscriptions for a channel ID or for the current authenticated user. + +=cut + +sub subscription_videos { + my ($self, $channel_id, $order) = @_; + + my $max_results = $self->get_maxResults(); + + my @subscription_items; + my $next_page_token; + + while (1) { + + my $url = $self->_make_subscriptions_url( + order => $self->get_subscriptions_order, + maxResults => 50, + part => 'snippet,contentDetails', + ($channel_id and $channel_id ne 'mine') + ? (channelId => $channel_id) + : do { $self->get_access_token() // return; (mine => 'true') }, + defined($next_page_token) ? (pageToken => $next_page_token) : (), + ); + + my $subscriptions = $self->_get_results($url)->{results}; + + if ( ref($subscriptions) eq 'HASH' + and ref($subscriptions->{items}) eq 'ARRAY') { + push @subscription_items, @{$subscriptions->{items}}; + } + + $next_page_token = $subscriptions->{nextPageToken} || last; + } + + my (undef, undef, undef, $mday, $mon, $year) = localtime; + + $mon += 1; + $year += 1900; + + my @videos; + foreach my $channel (@subscription_items) { + + my $new_items = $channel->{contentDetails}{newItemCount}; + + # Ignore channels with zero new items + $new_items > 0 || next; + + # Set the number of results + $self->set_maxResults(1); # don't load more than 1 video from each channel + # maybe, this value should be configurable (?) + + my $uploads = $self->uploads($channel->{snippet}{resourceId}{channelId}); + + (ref($uploads) eq 'HASH' and ref($uploads->{results}) eq 'HASH' and ref($uploads->{results}{items}) eq 'ARRAY') + || return; + + my $items = $uploads->{results}{items}; + + # Get and store the video uploads from each channel + foreach my $item (@$items) { + my $publishedAt = $item->{snippet}{publishedAt}; + my ($p_year, $p_mon, $p_mday) = $publishedAt =~ /^(\d{4})-(\d{2})-(\d{2})/; + + my $year_diff = $year - $p_year; + my $mon_diff = $mon - $p_mon; + my $mday_diff = $mday - $p_mday; + + my $days_diff = $year_diff * 365.2422 + $mon_diff * 30.436875 + $mday_diff; + + # Ignore old entries + if ($days_diff > 3) { + next; + } + + push @videos, $item; + } + + # Stop when the limit is reached + last if (@videos >= $max_results); + } + + # When there are no new videos, load one from each channel + if ($#videos == -1) { + foreach my $channel (@subscription_items) { + $self->set_maxResults(1); + push @videos, @{$self->uploads($channel->{snippet}{resourceId}{channelId})->{results}{items}}; + last if (@videos >= $max_results); + } + } + + $self->set_maxResults($max_results); + + state $parse_time_re = qr/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/; + + @videos = + sort { + my ($y1, $M1, $d1, $h1, $m1, $s1) = $a->{snippet}{publishedAt} =~ $parse_time_re; + my ($y2, $M2, $d2, $h2, $m2, $s2) = $b->{snippet}{publishedAt} =~ $parse_time_re; + + ($y2 <=> $y1) || ($M2 <=> $M1) || ($d2 <=> $d1) || ($h2 <=> $h1) || ($m2 <=> $m1) || ($s2 <=> $s1) + } @videos; + + return {results => {pageInfo => {totalResults => $#videos + 1}, items => \@videos}}; +} + +=head2 subscription_videos_from_username($username) + +Retrieve the video subscriptions for a username. + +=cut + +sub subscription_videos_from_username { + my ($self, $username) = @_; + $self->subscription_videos($self->channel_id_from_username($username) // $username); +} + +=head2 subscriptions_from_channelID(%args) + +Get subscriptions for the specified channel ID. + +=head2 subscriptions_info($subscriptionID, %args) + +Get details for the comma-separated subscriptionID(s). + +=head3 HASH '%args' supports the following pairs: + + %args = ( + part => {contentDetails,id,snippet}, + forChannelId => $channelID, + maxResults => [0-50], + order => {alphabetical, relevance, unread}, + pageToken => {$nextPageToken, $prevPageToken}, + ); + +=cut + +{ + no strict 'refs'; + foreach my $method ( + { + key => 'id', + name => 'subscriptions_info', + }, + { + key => 'channelId', + name => 'subscriptions_from_channel_id', + } + ) { + *{__PACKAGE__ . '::' . $method->{name}} = sub { + my ($self, $id, %args) = @_; + return $self->_get_results($self->_make_subscriptions_url($method->{key} => $id, %args)); + }; + } +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::Subscriptions + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::Subscriptions diff --git a/lib/WWW/PipeViewer/Utils.pm b/lib/WWW/PipeViewer/Utils.pm new file mode 100644 index 0000000..79e4ab1 --- /dev/null +++ b/lib/WWW/PipeViewer/Utils.pm @@ -0,0 +1,863 @@ +package WWW::PipeViewer::Utils; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::Utils - Various utils. + +=head1 SYNOPSIS + + use WWW::PipeViewer::Utils; + + my $yv_utils = WWW::PipeViewer::Utils->new(%opts); + + print $yv_utils->format_time(3600); + +=head1 SUBROUTINES/METHODS + +=head2 new(%opts) + +Options: + +=over 4 + +=item thousand_separator => "" + +Character used as thousand separator. + +=item months => [] + +Month names for I + +=item youtube_url_format => "" + +A youtube URL format for sprintf(format, videoID). + +=back + +=cut + +sub new { + my ($class, %opts) = @_; + + my $self = bless { + thousand_separator => q{,}, + youtube_url_format => 'https://www.youtube.com/watch?v=%s', + }, $class; + + $self->{months} = [ + qw( + Jan Feb Mar + Apr May Jun + Jul Aug Sep + Oct Nov Dec + ) + ]; + + foreach my $key (keys %{$self}) { + $self->{$key} = delete $opts{$key} + if exists $opts{$key}; + } + + foreach my $invalid_key (keys %opts) { + warn "Invalid key: '${invalid_key}'"; + } + + return $self; +} + +=head2 extension($type) + +Returns the extension format from a given type. + +From a string like 'video/webm;+codecs="vp9"', it returns 'webm'. + +=cut + +sub extension { + my ($self, $type) = @_; + $type =~ /\bflv\b/i ? q{flv} + : $type =~ /\bopus\b/i ? q{opus} + : $type =~ /\b3gpp?\b/i ? q{3gp} + : $type =~ m{^video/(\w+)} ? $1 + : $type =~ m{^audio/(\w+)} ? $1 + : $type =~ /\bwebm\b/i ? q{webm} + : q{mp4}; +} + +=head2 format_time($sec) + +Returns time from seconds. + +=cut + +sub format_time { + my ($self, $sec) = @_; + $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; +} + +=head2 format_duration($duration) + +Return seconds from duration (PT1H20M10S). + +=cut + +# PT5M3S -> 05:03 +# PT1H20M10S -> 01:20:10 +# PT16S -> 00:16 + +sub format_duration { + my ($self, $duration) = @_; + + $duration // return 0; + my ($hour, $min, $sec) = (0, 0, 0); + + $hour = $1 if ($duration =~ /(\d+)H/); + $min = $1 if ($duration =~ /(\d+)M/); + $sec = $1 if ($duration =~ /(\d+)S/); + + $hour * 60 * 60 + $min * 60 + $sec; +} + +=head2 format_date($date) + +Return string "04 May 2010" from "2010-05-04T00:25:55.000Z" + +=cut + +sub format_date { + my ($self, $date) = @_; + + # 2010-05-04T00:25:55.000Z + # to: 04 May 2010 + + $date =~ s{^ + (?\d{4}) + - + (?\d{2}) + - + (?\d{2}) + .* + } + {$+{day} $self->{months}[$+{month} - 1] $+{year}}x; + + return $date; +} + +=head2 date_to_age($date) + +Return the (approximated) age for a given date of the form "2010-05-04T00:25:55.000Z". + +=cut + +sub date_to_age { + my ($self, $date) = @_; + + $date =~ m{^ + (?\d{4}) + - + (?\d{2}) + - + (?\d{2}) + [a-zA-Z] + (?\d{2}) + : + (?\d{2}) + : + (?\d{2}) + }x || return undef; + + my ($sec, $min, $hour, $day, $month, $year) = gmtime(time); + + $year += 1900; + $month += 1; + + my $lambda = sub { + + if ($year == $+{year}) { + if ($month == $+{month}) { + if ($day == $+{day}) { + if ($hour == $+{hour}) { + if ($min == $+{min}) { + return join(' ', $sec - $+{sec}, 'seconds'); + } + return join(' ', $min - $+{min}, 'minutes'); + } + return join(' ', $hour - $+{hour}, 'hours'); + } + return join(' ', $day - $+{day}, 'days'); + } + return join(' ', $month - $+{month}, 'months'); + } + + if ($year - $+{year} == 1) { + my $month_diff = $+{month} - $month; + if ($month_diff > 0) { + return join(' ', 12 - $month_diff, 'months'); + } + } + + return join(' ', $year - $+{year}, 'years'); + }; + + my $age = $lambda->(); + + if ($age =~ /^1\s/) { # singular mode + $age =~ s/s\z//; + } + + return $age; +} + +=head2 has_entries($result) + +Returns true if a given result has entries. + +=cut + +sub has_entries { + my ($self, $result) = @_; + + if (ref($result->{results}) eq 'HASH') { + + foreach my $type(qw(comments videos playlists)) { + if (exists $result->{results}{$type}) { + return scalar @{$result->{results}{$type}} > 0; + } + } + + my $type = $result->{results}{type} // ''; + + if ($type eq 'playlist') { + return $result->{results}{videoCount} > 0; + } + } + + if (ref($result->{results}) eq 'ARRAY') { + return scalar(@{$result->{results}}) > 0; + } + + if (ref($result->{results}) eq 'HASH' and not keys %{$result->{results}}) { + return 0; + } + + return 1; # maybe? + #ref($result) eq 'HASH' and ($result->{results}{pageInfo}{totalResults} > 0); +} + +=head2 normalize_video_title($title, $fat32safe) + +Replace file-unsafe characters and trim spaces. + +=cut + +sub normalize_video_title { + my ($self, $title, $fat32safe) = @_; + + if ($fat32safe) { + $title =~ s/: / - /g; + $title =~ tr{:"*/?\\|}{;'+%!%%}; # " + $title =~ tr/<>//d; + } + else { + $title =~ tr{/}{%}; + } + + join(q{ }, split(q{ }, $title)); +} + +=head2 format_text(%opt) + +Formats a text with information from streaming and video info. + +The structure of C<%opt> is: + + ( + streaming => HASH, + info => HASH, + text => STRING, + escape => BOOL, + fat32safe => BOOL, + ) + +=cut + +sub format_text { + my ($self, %opt) = @_; + + my $streaming = $opt{streaming}; + my $info = $opt{info}; + my $text = $opt{text}; + my $escape = $opt{escape}; + 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) }, + 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) }, + 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) }, + CAPTION => sub { $self->get_caption($info) }, + PUBLISHED => sub { $self->get_publication_date($info) }, + AGE => sub { $self->get_publication_age($info) }, + AGE_SHORT => sub { $self->get_publication_age_approx($info) }, + DESCRIPTION => sub { $self->get_description($info) }, + + RATING => sub { + my $likes = $self->get_likes($info) // 0; + my $dislikes = $self->get_dislikes($info) // 0; + + my $rating = 0; + if ($likes + $dislikes > 0) { + $rating = $likes / ($likes + $dislikes) * 5; + } + + sprintf('%.2f', $rating); + }, + + ( + defined($streaming) + ? ( + RESOLUTION => sub { + $streaming->{resolution} =~ /^\d+\z/ + ? $streaming->{resolution} . 'p' + : $streaming->{resolution}; + }, + + ITAG => sub { $streaming->{streaming}{itag} }, + SUB => sub { $streaming->{srt_file} }, + VIDEO => sub { $streaming->{streaming}{url} }, + FORMAT => sub { $self->extension($streaming->{streaming}{type}) }, + + AUDIO => sub { + ref($streaming->{streaming}{__AUDIO__}) eq 'HASH' + ? $streaming->{streaming}{__AUDIO__}{url} + : q{}; + }, + + AOV => sub { + ref($streaming->{streaming}{__AUDIO__}) eq 'HASH' + ? $streaming->{streaming}{__AUDIO__}{url} + : $streaming->{streaming}{url}; + }, + ) + : () + ), + + URL => sub { sprintf($self->{youtube_url_format}, $self->get_video_id($info)) }, + ); + + my $tokens_re = do { + local $" = '|'; + qr/\*(@{[keys %special_tokens]})\*/; + }; + + my %special_escapes = ( + a => "\a", + b => "\b", + e => "\e", + f => "\f", + n => "\n", + r => "\r", + t => "\t", + ); + + my $escapes_re = do { + local $" = q{}; + qr/\\([@{[keys %special_escapes]}])/; + }; + + $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; +} + +=head2 set_thousands($num) + +Return the number with thousand separators. + +=cut + +sub set_thousands { # ugly, but fast + my ($self, $n) = @_; + + return 0 unless $n; + length($n) > 3 or return $n; + + my $l = length($n) - 3; + my $i = ($l - 1) % 3 + 1; + my $x = substr($n, 0, $i) . $self->{thousand_separator}; + + while ($i < $l) { + $x .= substr($n, $i, 3) . $self->{thousand_separator}; + $i += 3; + } + + return $x . substr($n, $i); +} + +=head2 get_video_id($info) + +Get videoID. + +=cut + +sub get_video_id { + my ($self, $info) = @_; + $info->{videoId}; + + #~ ref($info->{id}) eq 'HASH' ? $info->{id}{videoId} + #~ : exists($info->{snippet}{resourceId}{videoId}) ? $info->{snippet}{resourceId}{videoId} + #~ : exists($info->{contentDetails}{videoId}) ? $info->{contentDetails}{videoId} + #~ : exists($info->{contentDetails}{playlistItem}{resourceId}{videoId}) + #~ ? $info->{contentDetails}{playlistItem}{resourceId}{videoId} + #~ : exists($info->{contentDetails}{upload}{videoId}) ? $info->{contentDetails}{upload}{videoId} + #~ : do { + #~ my $id = $info->{id} // return undef; + + #~ if (length($id) != 11) { + #~ return undef; + #~ } + + #~ $id; + #~ }; +} + +sub get_playlist_id { + my ($self, $info) = @_; + $info->{playlistId}; +} + +sub get_playlist_video_count { + my ($self, $info) = @_; + $info->{videoCount}; +} + +=head2 get_description($info) + +Get description. + +=cut + +sub get_description { + my ($self, $info) = @_; + + my $desc = $info->{descriptionHtml} // $info->{description} // ''; + + require URI::Escape; + require HTML::Entities; + + # Decode external links + $desc =~ s{.*?}{ + my $url = $1; + if ($url =~ /(?:^|;)q=([^&]+)/) { + URI::Escape::uri_unescape($1); + } + else { + $url; + } + }segi; + + # Decode hashtags + $desc =~ s{(.*?)}{$1}sgi; + + # Decode internal links to videos / playlists + $desc =~ s{(https://www\.youtube\.com)/watch\?.*?}{ + my $url = $2; + my $params = URI::Escape::uri_unescape($1); + "$url/$params"; + }segi; + + # Decode internal youtu.be links + $desc =~ s{(https://youtu\.be)/.*?}{ + my $url = $2; + my $params = URI::Escape::uri_unescape($1); + "$url/$params"; + }segi; + + # Decode other internal links + $desc =~ s{.*?}{https://youtube.com/$1}sgi; + + $desc =~ s{
}{\n}gi; + $desc =~ s{.*?}{$1}sgi; + $desc =~ s/<.*?>//gs; + + $desc = HTML::Entities::decode_entities($desc); + $desc =~ s/^\s+//; + + if (not $desc =~ /\S/) { + $desc = $info->{description} // ''; + } + + ($desc =~ /\S/) ? $desc : 'No description available...'; +} + +=head2 get_title($info) + +Get title. + +=cut + +sub get_title { + my ($self, $info) = @_; + $info->{title}; +} + +=head2 get_thumbnail_url($info;$type='default') + +Get thumbnail URL. + +=cut + +sub get_thumbnail_url { + my ($self, $info, $type) = @_; + + if (exists $info->{videoId}) { + $info->{type} = 'video'; + } + + if ($info->{type} eq 'playlist') { + return $info->{playlistThumbnail}; + } + + if ($info->{type} eq 'channel') { + ref($info->{authorThumbnails}) eq 'ARRAY' or return ''; + return $info->{authorThumbnails}[0]{url}; + } + + ref($info->{videoThumbnails}) eq 'ARRAY' or return ''; + + my @thumbs = @{$info->{videoThumbnails}}; + my @wanted = grep{$_->{quality} eq $type} @thumbs; + + my $url; + + if (@wanted) { + $url = $wanted[0]{url}; + } + else { + warn "[!] Couldn't find thumbnail of type <<$type>>..."; + $url = $thumbs[0]{url}; + } + + # Clean URL of trackers and other junk + $url =~ s/\.(?:jpg|png|webp)\K\?.*//; + + return $url; +} + +sub get_channel_title { + my ($self, $info) = @_; + #$info->{snippet}{channelTitle} || $self->get_channel_id($info); + $info->{author}; +} + +sub get_author { + my ($self, $info) = @_; + $info->{author}; +} + +sub get_comment_id { + my ($self, $info) = @_; + $info->{commentId}; +} + +sub get_comment_content { + my ($self, $info) = @_; + $info->{content}; +} + +sub get_id { + my ($self, $info) = @_; + #$info->{id}; + $info->{videoId}; +} + +sub get_channel_id { + my ($self, $info) = @_; + #$info->{snippet}{resourceId}{channelId} // $info->{snippet}{channelId}; + $info->{authorId}; +} + +sub get_category_id { + my ($self, $info) = @_; + #$info->{snippet}{resourceId}{categoryId} // $info->{snippet}{categoryId}; + #"unknown"; + $info->{genre} // 'Unknown'; +} + +sub get_category_name { + my ($self, $info) = @_; + + state $categories = { + 1 => 'Film & Animation', + 2 => 'Autos & Vehicles', + 10 => 'Music', + 15 => 'Pets & Animals', + 17 => 'Sports', + 19 => 'Travel & Events', + 20 => 'Gaming', + 22 => 'People & Blogs', + 23 => 'Comedy', + 24 => 'Entertainment', + 25 => 'News & Politics', + 26 => 'Howto & Style', + 27 => 'Education', + 28 => 'Science & Technology', + 29 => 'Nonprofits & Activism', + }; + + #$categories->{$self->get_category_id($info) // ''} // 'Unknown'; + + $info->{genre} // 'Unknown'; +} + +sub get_publication_date { + my ($self, $info) = @_; + #$self->format_date($info->{snippet}{publishedAt}); + #$self->format_date + require Time::Piece; + my $time = Time::Piece->new($info->{published}); + $time->strftime("%d %B %Y"); +} + +sub get_publication_age { + my ($self, $info) = @_; + ($info->{publishedText} // '') =~ s/\sago\z//r;; +} + +sub get_publication_age_approx { + my ($self, $info) = @_; + + my $age = $self->get_publication_age($info) // ''; + + if ($age =~ /hour|min|sec/) { + return "0d"; + } + + if ($age =~ /^(\d+) day/) { + return "$1d"; + } + + if ($age =~ /^(\d+) week/) { + return "$1w"; + } + + if ($age =~ /^(\d+) month/) { + return "$1m"; + } + + if ($age =~ /^(\d+) year/) { + return "$1y"; + } + + return $age; +} + +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}) { + return 'LIVE'; + } + + $self->format_time($self->get_duration($info)); + + #$self->format_time($self->get_duration($info)); +} + +sub get_definition { + my ($self, $info) = @_; + #uc($info->{contentDetails}{definition} // '-'); + #...; + "unknown"; +} + +sub get_dimension { + my ($self, $info) = @_; + #uc($info->{contentDetails}{dimension}); + #...; + "unknown"; +} + +sub get_caption { + my ($self, $info) = @_; + #$info->{contentDetails}{caption}; + #...; + "unknown"; +} + +sub get_views { + my ($self, $info) = @_; + $info->{viewCount} // 0; +} + +sub get_views_approx { + my ($self, $info) = @_; + my $views = $self->get_views($info); + + if ($views < 1000) { + return $views; + } + + if ($views >= 10 * 1e9) { # ten billions + return sprintf("%dB", int($views / 1e9)); + } + + if ($views >= 1e9) { # billions + return sprintf("%.2gB", $views / 1e9); + } + + if ($views >= 10 * 1e6) { # ten millions + return sprintf("%dM", int($views / 1e6)); + } + + if ($views >= 1e6) { # millions + return sprintf("%.2gM", $views / 1e6); + } + + if ($views >= 10 * 1e3) { # ten thousands + return sprintf("%dK", int($views / 1e3)); + } + + if ($views >= 1e3) { # thousands + return sprintf("%.2gK", $views / 1e3); + } + + return $views; +} + +sub get_likes { + my ($self, $info) = @_; + $info->{likeCount} // 0; +} + +sub get_dislikes { + my ($self, $info) = @_; + $info->{dislikeCount} // 0; +} + +sub get_comments { + my ($self, $info) = @_; + #$info->{statistics}{commentCount}; + 1; +} + +{ + no strict 'refs'; + foreach my $pair ( + [playlist => {'playlist' => 1}], + [channel => {'channel' => 1}], + [video => {'video' => 1, 'playlistItem' => 1}], + [subscription => {'subscription' => 1}], + [activity => {'activity' => 1}], + ) { + + *{__PACKAGE__ . '::' . 'is_' . $pair->[0]} = sub { + my ($self, $item) = @_; + + if ($pair->[0] eq 'video') { + return 1 if exists $item->{videoId}; + } + + exists $pair->[1]{$item->{type} // ''}; + + #~ if (ref($item->{id}) eq 'HASH') { + #~ if (exists $pair->[1]{$item->{id}{kind}}) { + #~ return 1; + #~ } + #~ } + #~ elsif (exists $item->{kind}) { + #~ if (exists $pair->[1]{$item->{kind}}) { + #~ return 1; + #~ } + #~ } + + #~ return; + }; + + } +} + +sub is_channelID { + my ($self, $id) = @_; + $id || return; + $id eq 'mine' or $id =~ /^UC[-a-zA-Z0-9_]{22}\z/; +} + +sub is_videoID { + my ($self, $id) = @_; + $id || return; + $id =~ /^[-a-zA-Z0-9_]{11}\z/; +} + +sub period_to_date { + my ($self, $amount, $period) = @_; + + state $day = 60 * 60 * 24; + state $week = $day * 7; + state $month = $day * 30.4368; + state $year = $day * 365.242; + + my $time = $amount * ( + $period =~ /^d/i ? $day + : $period =~ /^w/i ? $week + : $period =~ /^m/i ? $month + : $period =~ /^y/i ? $year + : 0 + ); + + my $now = time; + my @time = gmtime($now - $time); + join('-', $time[5] + 1900, sprintf('%02d', $time[4] + 1), sprintf('%02d', $time[3])) . 'T' + . join(':', sprintf('%02d', $time[2]), sprintf('%02d', $time[1]), sprintf('%02d', $time[0])) . 'Z'; +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::Utils + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2012-2020 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::Utils diff --git a/lib/WWW/PipeViewer/VideoCategories.pm b/lib/WWW/PipeViewer/VideoCategories.pm new file mode 100644 index 0000000..a398c84 --- /dev/null +++ b/lib/WWW/PipeViewer/VideoCategories.pm @@ -0,0 +1,63 @@ +package WWW::PipeViewer::VideoCategories; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::VideoCategories - videoCategory resource handler. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + my $cats = $obj->video_categories(); + +=head1 SUBROUTINES/METHODS + +=cut + +=head2 video_categories() + +Return video categories for a specific region ID. + +=cut + +sub video_categories { + my ($self) = @_; + + return [{id => "music", title => "Music"}, + {id => "gaming", title => "Gaming"}, + {id => "news", title => "News"}, + {id => "movies", title => "Movies"}, + {id => "trending", title => "Trending"}, + {id => "popular", title => "Popular"}, + ]; +} + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::VideoCategories + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::VideoCategories diff --git a/lib/WWW/PipeViewer/Videos.pm b/lib/WWW/PipeViewer/Videos.pm new file mode 100644 index 0000000..ec215ec --- /dev/null +++ b/lib/WWW/PipeViewer/Videos.pm @@ -0,0 +1,243 @@ +package WWW::PipeViewer::Videos; + +use utf8; +use 5.014; +use warnings; + +=head1 NAME + +WWW::PipeViewer::Videos - videos handler. + +=head1 SYNOPSIS + + use WWW::PipeViewer; + my $obj = WWW::PipeViewer->new(%opts); + my $info = $obj->video_details($videoID); + +=head1 SUBROUTINES/METHODS + +=cut + +sub _make_videos_url { + my ($self, %opts) = @_; + return $self->_make_feed_url('videos', %opts); +} + +{ + no strict 'refs'; + foreach my $part ( + qw( + id + snippet + contentDetails + fileDetails + player + liveStreamingDetails + processingDetails + recordingDetails + statistics + status + suggestions + topicDetails + ) + ) { + *{__PACKAGE__ . '::' . 'video_' . $part} = sub { + my ($self, $id) = @_; + return $self->_get_results($self->_make_videos_url(id => $id, part => $part)); + }; + } +} + +=head2 trending_videos_from_category($category_id) + +Get popular videos from a category ID. + +=cut + +sub trending_videos_from_category { + my ($self, $category) = @_; + + if (defined($category) and $category eq 'popular') { + return $self->popular_videos; + } + + if (defined($category) and $category eq 'trending') { + $category = undef; + } + + return $self->_get_results($self->_make_feed_url('trending', (defined($category) ? (type => $category) : ()))); +} + +=head2 my_likes() + +Get the videos liked by the authenticated user. + +=cut + +sub my_likes { + my ($self) = @_; + $self->get_access_token() // return; + $self->_get_results($self->_make_videos_url(myRating => 'like', pageToken => $self->page_token)); +} + +=head2 my_dislikes() + +Get the videos disliked by the authenticated user. + +=cut + +sub my_dislikes { + my ($self) = @_; + $self->get_access_token() // return; + $self->_get_results($self->_make_videos_url(myRating => 'dislike', pageToken => $self->page_token)); +} + +=head2 send_rating_to_video($videoID, $rating) + +Send rating to a video. $rating can be either 'like' or 'dislike'. + +=cut + +sub send_rating_to_video { + my ($self, $video_id, $rating) = @_; + + if ($rating eq 'none' or $rating eq 'like' or $rating eq 'dislike') { + my $url = $self->_simple_feeds_url('videos/rate', id => $video_id, rating => $rating); + return defined($self->lwp_post($url, $self->_auth_lwp_header())); + } + + return; +} + +=head2 like_video($videoID) + +Like a video. Returns true on success. + +=cut + +sub like_video { + my ($self, $video_id) = @_; + $self->send_rating_to_video($video_id, 'like'); +} + +=head2 dislike_video($videoID) + +Dislike a video. Returns true on success. + +=cut + +sub dislike_video { + my ($self, $video_id) = @_; + $self->send_rating_to_video($video_id, 'dislike'); +} + +=head2 videos_details($id, $part) + +Get info about a videoID, such as: channelId, title, description, +tags, and categoryId. + +Available values for I are: I, I, I +I, I, I and I. + +C<$part> string can contain more values, comma-separated. + +Example: + + part => 'snippet,contentDetails,statistics' + +When C<$part> is C, it defaults to I. + +=cut + +sub video_details { + my ($self, $id, $fields) = @_; + + $fields //= $self->basic_video_info_fields; + my $info = $self->_get_results($self->_make_feed_url("videos/$id", fields => $fields))->{results}; + + if (ref($info) eq 'HASH' and exists $info->{videoId} and exists $info->{title}) { + return $info; + } + + 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); + + if (exists $video->{videoDetails}) { + $video = $video->{videoDetails}; + } + else { + return; + } + + my %details = ( + title => $video->{title}, + videoId => $video->{videoId}, + + videoThumbnails => [ + map { + scalar { + quality => 'medium', + url => $_->{url}, + width => $_->{width}, + height => $_->{height}, + } + } @{$video->{thumbnail}{thumbnails}} + ], + + liveNow => $video->{isLiveContent}, + description => $video->{shortDescription}, + lengthSeconds => $video->{lengthSeconds}, + + keywords => $video->{keywords}, + viewCount => $video->{viewCount}, + + author => $video->{author}, + authorId => $video->{channelId}, + rating => $video->{averageRating}, + ); + + return \%details; +} + +=head2 Return details + +Each function returns a HASH ref, with a key called 'results', and another key, called 'url'. + +The 'url' key contains a string, which is the URL for the retrieved content. + +The 'results' key contains another HASH ref with the keys 'etag', 'items' and 'kind'. +From the 'results' key, only the 'items' are relevant to us. This key contains an ARRAY ref, +with a HASH ref for each result. An example of the item array's content are shown below. + +=cut + +=head1 AUTHOR + +Trizen, C<< >> + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc WWW::PipeViewer::Videos + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2013-2015 Trizen. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See L for more information. + +=cut + +1; # End of WWW::PipeViewer::Videos diff --git a/share/gtk-pipe-viewer.desktop b/share/gtk-pipe-viewer.desktop new file mode 100644 index 0000000..b34f71d --- /dev/null +++ b/share/gtk-pipe-viewer.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=GTK Pipe Viewer +Version=1.0 +Comment=Search and play YouTube videos. +Exec=gtk-pipe-viewer +Icon=gtk-pipe-viewer +StartupNotify=false +Terminal=false +Type=Application +Categories=AudioVideo;GTK; diff --git a/share/gtk-pipe-viewer.glade b/share/gtk-pipe-viewer.glade new file mode 100644 index 0000000..1cd7bd0 --- /dev/null +++ b/share/gtk-pipe-viewer.glade @@ -0,0 +1,3447 @@ + + + + + + + + + + + 1 + 50 + 1 + 10 + + + 1 + 4096 + 1 + 1 + 10 + + + 1 + 1000 + 1 + 1 + 10 + + + True + False + emblem-downloads + + + Search for YouTube videos... + + + + + True + False + gtk-go-forward + + + True + False + gtk-go-back + + + True + False + gtk-missing-image + + + True + False + emblem-documents + + + True + False + applications-multimedia + + + True + False + + + Videos + True + False + image17 + False + + + + + + Playlists + True + False + image14 + False + + + + + + gtk-remove + True + False + Remove the selected user from list... + True + True + + + + + + True + False + emblem-documents + + + True + False + emblem-favorite + + + True + False + application-exit + + + True + False + + + True + False + go-down + + + True + False + go-up + + + True + False + dialog-password + + + True + False + gtk-refresh + + + True + False + emblem-important + + + True + False + applications-internet + + + True + False + mail-reply-all + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + False + video-display + + + True + False + + + + + True + False + vertical + + + True + False + + + True + False + Menu + + + True + False + + + Saved channels + True + False + See your list of saved channels + False + + + + + + CLI Pipe Viewer + True + False + 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 + False + + + + + + Warnings log + True + False + Show the warnings window + image76 + False + + + + + + gtk-preferences + True + False + Adjust application settings. (CTRL+P) + True + True + + + + + + True + False + + + + + gtk-quit + True + False + Quit the application. (CTRL+Q) + True + True + + + + + + + + + + True + False + View + + + True + False + + + Previous results + True + False + False + icon-previous + False + + + + + + Next results + True + False + False + icon-next + False + + + + + + + + + + True + False + History + + + True + False + + + + + + + True + False + Help + True + + + False + + + gtk-help + True + False + Show a help window. (CTRL+H) + True + True + + + + + + + + + gtk-about + True + False + True + True + + + + + + + + + + False + True + 0 + + + + + True + False + + + True + True + never + + + True + True + liststore1 + True + 1 + 9 + + + + + + + False + True + Thumbnails + True + True + + + + 1 + + + + + + + True + fixed + 100 + Title + True + True + True + + + + 0 + 3 + + + + + + + True + Info + True + True + + + + 2 + + + + + + + + + True + True + + + + + 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 + True + never + never + + + True + False + + + True + False + vertical + + + True + True + True + + + True + never + + + True + False + + + True + False + vertical + + + True + False + vertical + + + Subscriptions + True + True + True + image53 + top + + + + False + False + 0 + + + + + Favorited videos + True + True + True + image51 + top + + + + False + False + 1 + + + + + Liked videos + True + True + True + image59 + top + + + + False + False + 2 + + + + + Disliked videos + True + True + True + image54 + top + + + + False + False + 3 + + + + + Uploads + True + True + True + icon_from_pixbuf + top + + + + False + False + 4 + + + + + Activity + True + True + True + image7 + top + + + + False + False + 5 + + + + + Playlists + True + True + True + image5 + top + + + + False + False + 6 + + + + + Log out + True + True + True + image52 + top + + + + False + False + 7 + + + + + False + False + 50 + 0 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + + relevance + unread + alphabetical + + + + + + + + + True + False + <b>Subscriptions order:</b> + True + + + + + False + False + end + 1 + + + + + True + False + 0 + none + + + True + False + 0 + 0 + 9 + + + True + False + + + True + + 15 + False + False + + + True + True + 0 + + + + + True + False + 0 + + myself + username + channel ID + + + + + False + False + end + 1 + + + + + + + + + True + False + <b>Account:</b> + True + + + + + False + False + end + 2 + + + + + + + + + + + False + My panel + + + False + + + + + True + True + + + True + False + + + True + False + vertical + + + True + False + 0 + none + + + True + False + 12 + + + True + False + vertical + + + True + False + 0 + + video + playlist + channel + all + + + + False + False + 0 + + + + + + + + + True + False + <b>Type:</b> + True + + + + + False + False + 0 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + vertical + + + True + False + 0 + + relevance + rating + upload_date + view_count + + + + + False + False + 0 + + + + + + + + + True + False + <b>Order by:</b> + True + + + + + False + False + 1 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + short – less than 4 minutes long +medium – 4 to 20 minutes (inclusive) +long – longer than 20 minutes + 0 + + any + short + long + + + + + + + + + True + False + <b>Duration:</b> + True + + + + + False + False + 2 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + 0 + + any + true + + + + + + + + + True + False + <b>Video Caption:</b> + True + + + + + False + False + 3 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + 0 + + any + high + + + + + + + + + True + False + <b>Video Definition:</b> + True + + + + + False + False + 4 + + + + + True + True + True + + + + True + False + vertical + + + True + False + 0 + none + + + True + False + 12 + + + True + True + The maximum number of results per page. +Recommended: 10 + 2 + + False + False + False + adjustment1 + 1 + True + if-valid + + + + + + + + + True + False + <b>Results per page:</b> + True + + + + + False + False + 0 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + True + List videos, starting with a specific page. + + False + False + False + adjustment2 + 1 + True + if-valid + + + + + + + + + True + False + <b>Start with page:</b> + True + + + + + False + False + 1 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + + + True + False + + anytime + hour + today + week + month + year + + + + + False + False + end + 1 + + + + + + + + + True + False + <b>Published within:</b> + True + + + + + True + True + 2 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + True + Search in videos uploaded by a specific author. +Unless the author name is valid, this field is ignored. + + Insert a valid author name... + False + False + False + + + + + + + + + True + False + <b>From author:</b> + True + + + + + False + False + 3 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + vertical + + + Show thumbnails + True + True + False + True + + + + False + False + 0 + + + + + Fullscreen mode + True + True + False + True + + + + False + False + 1 + + + + + DASH support + True + True + False + True + + + + False + False + 2 + + + + + Audio only + True + True + False + True + + + + True + True + 3 + + + + + Clear search list + True + True + False + True + + + + True + True + 4 + + + + + + + + + True + False + <b>Other options:</b> + True + + + + + False + False + 5 + + + + + + + True + False + More options + + + + + False + False + 6 + + + + + True + False + True + center + + + gtk-find + True + True + True + True + + + + False + False + 0 + + + + + False + False + 14 + 7 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + Video resolution (default: best) +When the specified resolution is not found, the best available resolution is used. + 0 + + best + 2160p + 1440p + 1080p + 720p + 480p + 360p + 240p + + + + + + + + + True + False + <b>Resolution</b> + True + + + + + False + False + end + 8 + + + + + + + + + Search options + 1 + + + + + True + False + Settings + False + + + 1 + False + + + + + True + False + vertical + + + True + False + 0 + none + + + True + False + 12 + + + True + True + Insert an YouTube username + + False + False + + + + + + + + True + False + <b>Uploads:</b> + True + + + + + False + False + 3 + 0 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + True + Insert an YouTube username + + False + False + + + + + + + + True + False + <b>Favorites:</b> + True + + + + + False + False + 3 + 1 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + True + + False + False + + + + + + + + True + False + <b>Subscriptions:</b> + True + + + + + False + False + 2 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + True + + False + False + + + + + + + + True + False + <b>Likes:</b> + True + + + + + False + False + 3 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + True + Insert an YouTube username + + False + False + + + + + + + + True + False + <b>Playlists:</b> + True + + + + + False + False + 3 + 4 + + + + + True + False + 0 + 0 + none + + + True + False + 12 + + + True + False + 0 + + username + channel ID + + + + + + + + + True + False + <b>Channel type:</b> + True + + + + + False + False + end + 5 + + + + + 2 + + + + + True + False + Channel + + + 2 + False + + + + + True + True + + + True + True + liststore4 + 0 + + + + + + + True + Icon + True + True + + + + 2 + + + + + + + True + fixed + Category + True + True + True + + + + 0 + 1 + + + + + + + + + 3 + + + + + True + False + Categories + + + 3 + False + + + + + True + + + True + False + + + True + False + vertical + + + True + True + liststore6 + + + + + + + Icon + + + + 1 + + + + + + + Top + + + + 0 + 2 + + + + + + + True + True + 0 + + + + + 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 + False + 1 + + + + + + + + + 4 + + + + + True + False + Tops + + + 4 + False + + + + + True + True + 0 + + + + + + + + + True + True + 1 + + + + + True + False + 2 + + + False + True + 2 + + + + + True + True + + + + + True + True + 1 + + + + + + + False + 5 + center-on-parent + normal + __MAIN__ + GTK Pipe Viewer + Copyright © 2010-2020 Trizen + Written in Perl, Gtk3 and Glade. + https://github.com/trizen/pipe-viewer + https://github.com/trizen/pipe-viewer + Trizen https://github.com/trizen +Ovidiu D. Nițan <nitanovidiu@gmail.com> +Jookia https://github.com/Jookia +Andreas Hrubak https://github.com/bAndie91 +and others... https://github.com/trizen/pipe-viewer/graphs/contributors + PosixRU (main logo) http://zenway.ru/page/gtk-youtube-viewer + image-missing + artistic + + + + + + False + + + False + end + + + False + True + end + 0 + + + + + + + + + + False + Video details + True + center-on-parent + 400 + True + static + __MAIN__ + + + + + True + False + vertical + + + True + False + vertical + + + True + False + Details window label + True + fill + True + end + False + + + True + True + 0 + + + + + True + False + + + False + True + 1 + + + + + False + True + 0 + + + + + True + False + vertical + + + True + False + vertical + + + True + False + vertical + + + True + False + + + True + False + + + True + True + 0 + + + + + True + False + + + True + True + 1 + + + + + True + False + + + True + True + 2 + + + + + False + True + 0 + + + + + True + False + + + False + True + 1 + + + + + False + True + 0 + + + + + True + True + vertical + + + True + False + True + True + True + end + + + False + True + + + + + True + False + 0 + out + + + True + True + 12 + + + True + True + + + True + True + 2 + word + False + + + + + + + + + True + False + Description + + + + + + + + + + + + True + True + + + + + True + True + 1 + + + + + True + True + 0 + + + + + True + False + center + + + True + True + True + none + + + False + False + 0 + + + + + False + False + 1 + + + + + True + True + 1 + + + + + True + False + + + gtk-media-play + True + True + True + True + True + + + + True + True + 0 + + + + + Download + True + True + True + download_icon3 + + + + True + True + 1 + + + + + gtk-close + True + True + True + True + + + + True + True + 2 + + + + + False + False + 2 + + + + + + + False + 5 + Error! + True + center-on-parent + 300 + 200 + dialog + __MAIN__ + + + + + + + True + False + 1 + + + True + False + end + + + gtk-ok + True + True + True + True + True + + + + False + False + 0 + + + + + False + True + end + 0 + + + + + True + False + + + True + False + dialog-warning + + + False + False + 0 + + + + + True + False + Something went wrong... + + + + + + + + True + True + 1 + + + + + False + False + 0 + + + + + True + True + + + True + True + False + + + + + True + True + 2 + + + + + + button3 + + + + False + YouTube comments + True + center-on-parent + 450 + 400 + True + __MAIN__ + + + + + True + False + vertical + + + True + False + Comments window label + True + fill + True + end + False + + + False + True + 0 + + + + + True + True + vertical + + + True + True + in + + + True + True + liststore11 + + + + + + + Comments + True + + + + 0 + + + + + + + + + True + True + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + + + True + True + in + + + True + True + True + word + + + + + + + + + + + True + False + Write your comment: + + + + + + + + False + True + + + + + True + True + 1 + + + + + True + False + end + + + Send + True + True + True + image8 + + + + True + True + 0 + + + + + gtk-refresh + True + True + True + True + + + + True + True + 1 + + + + + gtk-close + True + True + True + True + + + + True + True + 2 + + + + + False + True + 2 + + + + + True + False + 10 + 10 + 6 + 6 + vertical + 2 + + + False + False + 3 + + + + + + + False + Help + center-on-parent + 480 + 400 + __MAIN__ + + + + + True + False + vertical + + + True + True + + + True + True + True + 1 + 3 + + + + + True + True + 0 + + + + + True + False + 5 + True + end + + + gtk-about + True + True + True + True + + + + True + True + 0 + + + + + gtk-close + True + True + True + True + + + + True + True + end + 1 + + + + + False + False + 1 + + + + + + + False + 5 + Login to YouTube + dialog + __MAIN__ + + + + + + + True + False + 2 + + + True + False + + + gtk-ok + True + True + True + True + + + + False + False + 0 + + + + + gtk-cancel + True + True + True + True + + + + False + False + 1 + + + + + False + False + end + 0 + + + + + True + False + Connect to your YouTube account + + + + + + + False + False + 1 + + + + + True + False + vertical + + + True + False + 0 + none + + + True + False + start + + + Click here to get the authentication code! + True + True + True + half + + + False + False + 0 + + + + + + + + + + True + False + <b>OAuth 2.0 authentication</b> + True + + + + + True + True + 7 + 0 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + True + + False + False + + + + + + + True + False + <b>Authentication code:</b> + True + + + + + False + False + 1 + + + + + Remember me + True + True + False + True + True + + + False + False + 2 + + + + + False + False + 2 + + + + + + button5 + button4 + + + + False + Preferences + center-on-parent + 600 + 480 + __MAIN__ + + + + + True + False + vertical + 2 + + + True + True + + + True + True + True + 2 + 2 + 2 + + + + + True + True + 0 + + + + + True + False + 5 + end + + + gtk-apply + True + True + True + True + + + + True + True + 0 + + + + + gtk-close + True + True + True + True + + + + True + True + end + 1 + + + + + False + False + 1 + + + + + + + False + Saved channels + center-on-parent + 400 + 300 + __MAIN__ + + + + + True + False + vertical + + + True + False + vertical + + + True + False + + + True + True + 0 + + + + + True + False + + + True + False + Channel name: + + + True + True + 0 + + + + + True + True + + False + False + + + + + True + True + 1 + + + + + True + False + Channel ID: + + + False + False + 2 + + + + + True + True + + False + False + + + + + True + True + 3 + + + + + gtk-add + True + True + True + True + + + + False + False + 5 + + + + + False + False + 1 + + + + + False + False + 0 + + + + + True + False + vertical + + + True + False + + + False + True + 0 + + + + + True + True + + + True + True + liststore2 + + + + + + + Icon + + + + 3 + + + + + + + Channel + + + + 1 + + + + + + + + + True + True + 1 + + + + + True + True + 1 + + + + + True + False + 5 + end + + + gtk-ok + True + True + True + True + + + + True + True + 0 + + + + + gtk-close + True + True + True + True + + + + True + True + 1 + + + + + False + False + 2 + + + + + + + False + 5 + Warnings log + center-on-parent + 320 + 260 + dialog + + + + + True + False + 1 + + + True + False + end + + + gtk-close + True + True + True + True + True + + + + False + False + 0 + + + + + False + True + end + 0 + + + + + True + False + + + True + False + Warnings log + + + + + + + + True + True + end + 0 + + + + + True + False + terminal + + + False + False + 1 + + + + + False + False + 1 + + + + + True + True + + + True + True + + + + + True + True + 2 + + + + + + button26 + + + diff --git a/share/icons/default_thumb.jpg b/share/icons/default_thumb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..79135d9d0c11046e1b3457278125a4370a74fc6b GIT binary patch literal 2310 zcmah~dpK0<8eelY!whSfG?+A)G&FT+MlLBP<#vV|o#|q0$B^5OW-2NahRB|gyF)3- zC6^qAP^oB!aZL|X;_o_rCAsQ`jR+(PsWKEAwPUb?p$h5Q;6PG@?KhgJdm(hY0AYcG*;0gjp0N?}|f&g1+1IPdj zKtQX7*1Kd#I6@78q0ks8O8lSuTXQONVBv(yW2T7%E&TqDO4Ya7C!82?A()%d&`6MyDAy$0bCNYoqxk-jjD*>EM;IKJ zeqY&vvpg2Dfo>z+nH!^+Ba%RXWtd7n4J&3JAMNS!w5SiUc*jE{KaH~t@sL}FOCQk? zd0KkaI?Hhw063RQw&xe?IqYHQcTA?9?0K+@231JcW%8c}{H%c`Tg;T$>;hQGb-MK6 zexZg4OWuCHDv?pIHaqr%ihz2P4!%`my< zdCBg$WDyg@{)8pG-W%6RITF(n|67niAwL?sGWS`P zDLaQG;fc3?4EVZJaX~ry_S2c26qw*KezkZ843~3g27S3Kcz2EYf-sgu?bpc%* zkuFX4=G+26{bC!-ZFm=bH>t+#kz#0IIVx`)87YW8@p|*xhQpVK>Y{(_x;6M{@`ne{ z6i4pK29JIoR8=R4^yS5L=KHm>C--#QryKuvR^pd*Id@BC-Sx`KtHyh*%=d=>(j56Z z^l{am%BySKbH2mI#>QtFxLoc*)z%^`@3^I{#b4wOV%7o1|BD3pw**@dfXGLnG*PIf z3J?U41Z^Wr6jN|irb98duwr>-W@Q)k4=9KmxB2>+FgV52OD#e>z~`~UF?nxua)MB$ zJDO{6l%8Mlxz%NZ;Vk3jd7mv)K??HC=*ginTmEcU4V$q(XEnpChSxy56{(w9FKsR$ zc@17kq;NyIty;ZUzE7P;hE>o^RawKd5}ntrUpMbD#a`W+H{9N&C7m3v%9_#IzW^N2 z#`l=MPoCY&eMd{}%SpH<(&xqQIT&)cYoy-5rY>DOdn{WFhzqMM>lb6u+|;t!wR_ba z4*s5&(1X7|+6E+MVneiO8{$-S{LhvZ^Z?oI3fy zbgnu&IPdWRqe^T7d&J_5Ol1F2 z^%9Z=0RK}K-#P&hj6e#DV_9S&@QasZe3ONP5g1H0}c`5_yH8+j*%-GQVrBX-3e~e?&mBsXqT!ZI^ z;q^!h7J5woG_&%sV~%-*yS4c#r;9rG6X*F3**`S0CG;OH!_Zsqf2}ro%9!!0K1xV1 zn=CuYgE_Xs#@7sN9aV}?GxeDyOTQO|rR&z)H*UTEB`w%y-q()$VN}W4K`t`9Lhxm- z?nWi9$$t)Fvi8zRtKQ)?ZaKxpho9i zss}{<8a#)^k{>s3I)!X{6-PL$dqsJkrW^%X<$h!41p=4bcPURal`|6~_K`A|r}hv@ zi$^PffLH{z-GDe2CC(KPLI6Vr-2iP2QV1a7ju51PZq!VG8V9Iypd&mJK*nK_LRTQ& z6`AQs(RQP11C&fxY9^KndD2xC0HGTt4yKy;7vfT>5Bz`XTTCzk0Z467*P+W`$zTK{ zOOWNq5yVet6iFz{JqK|wq+)h?F755l2Q=C~T<*_LEgwuHRE%3wCfd4F#{0yy>0`8e zVWs+<27`V{MFUAMKaa#IR694Vff!N~B^=xB{>j>!se=>K+N!m}sHL}EJs3f1PPdF& zj`Hw4}?zQXe*dvii+ped^}^g_rSQz2=||6|kn z>!%G|44@mkJH37GWsOMhev|-rV0TJ2*)X0cE=dogc3Ayh7FAB}Vozk-ws881qXAXH zUgp}Ef!eDjfxImK#b8#S)x|SYomtlPPOKZPoo&)y+~MJg2=^na5hH!ZJbOk*m4j{9 zofyOLRS)p^Icw%dqXY)SV2x<}9e@5|>_?tn{jumxR?G=`FOHo?YuWu!dgo&cqi=m= sNZ{a+z}!&pk70r!Cz~cUeiDB}lE19>;iD594?Ec32qxFRBw`LI3~& literal 0 HcmV?d00001 diff --git a/share/icons/feed.png b/share/icons/feed.png new file mode 100644 index 0000000000000000000000000000000000000000..923b9bd5d059d1b0e715dc168ce201b49d3dead9 GIT binary patch literal 901 zcmV;01A6?4P)Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L03dMy03dMz>(BoS00007bV*G`2ipuB z6dDz+%~tjR00RX{L_t(I%QccsYn)XWK+p5sJ2RP?WYRceJ2p0H>LOZfLTU+x79(yH z7cL4`Q2YZfgeZy&#VWY!N^l{Hpx{Dx1r?NjEc_@@v{F*AR9o95+SriRWHK}FymRk; zTtpXVdpL_DKR#R6%JA?b)mrVOQ=LX|2&f1sARq{cAPOR2NV~Jq`Lf$;&C8|d_dU9! z(Kstv082QZOdaA1iWC&*74?F6!8t)3rYb^VfYs}pOTJpGos@2lR(4o>pn}EB>dm1s@6=6`XB3-zWcb(#07!xG*CpbO1=7-aY;6(IBBF{q z!3<(<67w!@{^5JxEU1|qD2bMbAY@2o1UIq=KXwSWdkQI)k@d@nL_mpoBJ>immyo5` z4q90g;uS3w@RdQ_@J{^1EQOipkZK(ZJ?zTogdg4|cG`qoNO>am4ENI9?h{3C_+1lJ z9(x7fd;r^A#;%ge5*NMjPSBK<#pBJ~0>hma>c3gW?)s}#m(k@7G$^ECGN zBH`Q{r0?E_Y#n#t8Qj!U$nvj*3tteUl5#^4a9$z5L-_49wy_Kr(dHDz!PGOdifg$|(Ed5(6#I4)-`Y5I)QZT$BZFN#*YlZmBN65WL$uFKFZvI2q>d?FR z6;iE}dO7{|TXZjdPjPgTl=ndu`TgYwKO5WMIBp@c7`?<)BDS`OS)Y(6%ncHw65AbO zmV>Wgc=9{9tvXs0-)*(#SFaZj*PBCQG3yeuE~#gPej?>Y=x>nvwoNcnX9HBJj@G0T bHb40nW~yc?c2S9200000NkvXXu0mjf0_m2) literal 0 HcmV?d00001 diff --git a/share/icons/feed_gray.png b/share/icons/feed_gray.png new file mode 100644 index 0000000000000000000000000000000000000000..5e8468e6630731d502ebbf6b62e199b7e1f6a371 GIT binary patch literal 2161 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 z zUB!(HZEFz8F`_ic8Dsz%aSXnnk~bpePmJ;YpMI3Z3!h|tVVdQ+>sWYYFXx}TgT>Dt zB{>j`aM%T73AgNSGdVD{?ZW>6HP3B-?V4@YXyBTrMfeBKDewMS?O*y+X;0tG0`NGM? zP)V2w-1r@^_b%M?zYVYaQ+U^Z&nG#+`PB2H*R>drLd@zAJc3h$8jl(i-1o|e&}*d~ zuIL5{wuH_X5qM#dB_$ln(1bxG0JYT0j4Obx{2F{%04l#=S0rW&&1hky?yW#EX-h+i zvg7jRa7NUGof1-b8E92nTy0PNXv8d=eJi({r@Gb0LbrcfQN>mCYqLeQTO52I;v{Bb zVuG7*x{2P(8c8dml{Cm)POq1;)?H(ernK8_W@cuXpPw(lE}=cvL83xa8(HQr&TomB zjWOJL=bh}`yO(CO$*EJPc=E|7EA8-?zVv18x##ul-aSjB(WrbP;yA8+Y)dcO(8r7l zc)j;L`|PuP^{ZcHYHF(Djo<(N?*=T*W|MR0&e3YM@L5K$zd>uzB+FCsJj0XYH45f~ zVf%(gdTD6XOKbl8RmRGregA~ZQOsqTcRkVe^@)7%b;@I7;WJF69t&jF5gP@`$rKXF zak-#T9+oSc(_4;c!~+@!m)L#KbL{nJvGEI>er7+P``Ft!|LB3SJAa(RduEB^h(YSf zF7z2!rIEzA9E|hisX-b_W(;v@HXvH*MHOFU%}Y(G2HOV_^3;*_JXz1-oziGn+S9YN zcJJZB(_dul)_WOi%6sP+d;-(R@jfCaqM;taVPcORD7hLMcycc|49{A?mjrh_q=Wozw^`_ zpF6P_w727X;O?J*=YI=+>z~2*{uPe5Z{(44DL-;uqh=JQi19d;KruvG$kW1~4ttZZ z<#&Z4uV@hLIDBXoB-M;UcT**ts&fDR_p^KVZsIs5 zNfP$&-_NtpK3myYcDr3(e)(k%9XeDgDDpgKa&nR+Nf-BZMHcZE=Y!FWi7j@*k_{4!jgfwg3}PV0=Lu1skk|&+B-kVY z+kmVAzJa4flq9_T)It96!~Zi$tI@dsM`%s&BaVc-uN&uAzp%h|8xOK;EDk0( zK77X9DNe(!XncSU<$7D0+zkkDF{+I8#nS--qU?pTQ zZ~1=j&kpCxhKbwqd0}!3EnB5pg;`A>Ix(8 z(zqvN;nf$07^*e>Y|)ah*w^@)_0Dk1Uz+`B@tKDNMp@QOaVx)@s`ZdkHGr-t4^zWm zttA2LO*1ZK#PTX|`cghFBlnrI+Bp;*L~-C08*!6k*B|4?n{MKsdtcAn-~LV>c;EqU zx&0QVr)HR(m|${pvI0g`WqfK1E1`fvL?E(Un7gnAe2*MCf-$C|6-wT?9^Jk4(o2$PNl!nc(uy#hh};EG&2lA89U6rlvh%?ODR9e|$Gk3O6cRdf1w06YUt_BnPnvCsDh$k#J-}z=5vj;f+`0tRTL@~D` zzJWF(h+}*|p-YT236V{}HPDR)nKmJAV6=(EO;XyJW}Do!$!VjegUiNH*MWEfbrawx z=&$YOk3afDWc``s_1{bDz^%lQ5G97^=6ihmO}lx^kqI^ip4C;y@>0seT*ie9Df1Un z<}M7FzmRhAV#?~WW25IubAlD31fPqtLxRe%V}dUt$Pz~!(D#mA;|c%cw;ba;?mR%$ zh=LmB*6)L<>lys#e??qU`XEACcHkB-ue#}5?`TB8C&2WCH1DU{{S8hq)U4-2?@#W}4& zTS|lXO@6cG0y2c^AxxGAK3W_|ou(LqguA4H)}?T~w6?^6E$lvwjc7E2?CcE>yop0c zj&T12Z{YUV-Ok><`)RdW+;;QLtgfzBTm2}C1J@fFj13iMqM_8co~#oQ#>P68lA+OP z5XUjiW^)8|=H@Q2v9S?)zU6s!+iscJEICu%y5V=uSIs6{tyWMwD7ehwGKY5_)jXKI z7m_)A5M*mXL#n@un+$J=j?Rs3Zn#mf)xz)MQXFmxw)Ldj*P`>i0#m%pv%W{^=Strx zNfCNV?um`V8%NK3eC+VzP;np*Atw@tF%GfejHr4Bx#8q92h8kI$$gHrLqf%AC^PJJ z9ky-5N%1~RpiFT-L|Gx)Ra`Cspq(epmANxLmM>-@X$MU0Y0{pGkyyfZMj`i}MrV>E zx8BdjOV83>Tf^mQQ5%J;)P$}bMlDg#AU-70JQ2oQVvPzP{}?u6@+`(nf_DiP6PF|JQ!EKy|mwp(VnZl=M7g#q((16EdZHhRh+FFr4j ztB|SCOO^Fr&gyc?#f6Nyxjuv3;mb%@siNduHGD@J=a+^L@JgoPk#!ym5w~ ze9I9eHb4e9e=q153;+4wG0|ypHnUuChFq20d(z@Pz4ryUmX?$u8qKwcfQ#;sAqp>| zTukA4qyeN$mjxe;y@Rks3uip47}$Gdh{GjVj{>7^((_g@uda}#mC?8ky@KuVqPJyD zzm8_Wy;AcC)}gRhN1;^f6&q#g>ZY4+@*q$x<;u3=pe+W}VAKR3T^kA%rlzJ?TU%q{ z!UdLBR!9du5Mgp^s_0T&HgwdYD{(N&5itzX-Uwi-D$~=`s4A^itKwrzK&b=t>grnH zU<)8DBinlP8zS0KaK6;m+qs-xuSc`lq|@opZns1AUh)3HXE=P8(qvx?O5YQTV73cn zxBj8wO&!!G$F?_LD0UdR9!8`4`sb-vwQUCC*N*f3ku_zlpdUU1jE#A0Dhwir#!5#W z1A;)a9YH^Jg$s#zlB< zFkWyrgbryQp*XxK%{W{QsSCE628%Q-v2WniBH9EOcVE6)GkZ*u4Revor6V?z39 zRM(~o4N+1jsCA+7pcz)K#iAoydM|>}AXhWBnltr7T{Z+0_YzP=WrRyDtLCasq?-h_ zTK}JvUK7-u(|jevqh0{7n>3`CNUZDC_!|n_yPxT0-do(T{LY3-Ph*1@0tEA&)s=Og z`pQ$REUhpY^myr|mqP`pq2hYWpHh|a2K}z;<3i-zni4hA&$dV_x^*E%p?h?&-Tf4 zu%;;2)uBX2L<{gLTVX23K&LH?Pee2#Qzfx@rp4XlS}Zp_a0w# z%k+f{juaje6kfqr!tO(A9U7%lB?rFRNQjVer9VSWUSNu{WQxuLB9$US>b#&YlX#^Y znya;^uXli|5x=8`lUJ+|9x>1HwR+~w9S}w;++5|RRaOgD1b(fzoG}(tbm$adV`GCa zf9Xq{eEB8vEN5+PgI1?Wmb;3FbhUK{1t|$*T{2L#jRU zV0_Th_#zXM3cvsFz87PexaA!Xwa8JV=NR;Tus#%nCu|cwC(V$13C{os&P zMs+@@n2iYD=+OMF$Ou@0UAtr2ZBuZ#rX)|p8mGg#Y=DjRoGc%j#)xx8g;$s;dEb$x zj$|U@-a}KIUL5e{7Z+gvJuv?jzWUK$=BB^-v#9OR?K`5Pg^`FWiUR`RnZj(>NN@); zSd4~7o??9E=jsX%UaPfo<3i9@PAylNshi5R%ojLW{vBV_Yn9)Tuayl;Es0l}{%HAc z-5UBxSuLkUrXHnle1OY>u<)nGW z%JK?nnu2=P*1NRw7Sp?TkI1{!d*XPsg6&d?Dv_W@D0$y3%eHMCxwNz#{%)ao*i1VF z&9ARIBsUt3qL9LjY}227@+%xXxR0^1vC31T*XyCVRtZs@ZjF5L#WdUOT6dKhP`$e6 zC|8br%lkH$7k_CPYkKV=V#eK=)`)TaE1LymwgWi0`c+(PDp_h5uV^Nq3R*^)=yJgd>5OrA_GD*F) zjJ1`VMq-$piNiF`Ji$Ixa5V?5Qjo3)>G~S|wIygzkej$m<}ae!#M`V$eOtV>;63UT zV-<0wcU}aUA(tdHHnR=zF_;$k2xD3_+sc3Xr|;v;bFXKx5ly}4ry1XSkcgZ{WZ4xb ztSmdc4v$GBnAL=9P^(6j<=nJJ*tb8S(Xcg7+VJ%T1%VXQjaFs5nHRDWj?3uKOC=ve z!y=U~q_k#YzU9^#p1rWfpzGlJZ(;eXzsBh=eU^LQ@=mO@=ylf$ObK?CWpHDP)y;{) zv{R`ifF#pWk6!@YaivdC_ZJgCX()Z;5-Tnnx^bgvb1hHe_g_orLSf2Ht{^ja%6 z_HH!EwOul7yTC>Cs=C@I{vRSwH^{vZEobbRXwhj}Qm@1fC7DjbiEuze(5|nU zcLmP?^@i2u6z7yd7J$b4VRJ|^T20WB11wkk^_4}|7U$rW`_Z`3rQcr+#<=Qn8fubl zt^xhk2fKYE;0g!wbOS-vpK(uG;(Up~UQm)=Z!60_$9cD?OKY0vB@ zA|3FXnz!HjdQbjbN)Pu0TUbXam8nT`@^RQ12`KW3_$KQE3jU+D9LD=v9h<33U!_JF)C+-!8Xys0iE&ki9gcl%yKib5-qNC{s(h>e zUY}{fJ^-IHELUce8(*CcHbWfsBT;YBW__E~wQyxFqxviq3KlzjKsEuwu-%f9=dSYZ zt#5ZjZ#W5HuChhxFsy_N3AeIhZQDaFWM*Zl@aD)jM8sKDy*31*VH9}w>{$S^EWEbg z?~|r!kg+*eBsxaLc#1FDM-?Zcz9V59zW*8`V5y&vya>@`C>)nfwr1t$t|!|zyPLh% z<;BV`pnT{)c;I-jnC$1uRHUrd-$92dL+2#8VpL@?%2)sRX1c3~8gKdu@@xR=X?Nks z>=Kiti;Bi=!g%H$-258=!(5}H+Y}0Vy`T@W9i}&)}r{Bu@ z@r z^fyD9YDdgW#Nqm}MWKQXkb^6n!bU_<0cl*J@f}9JHSvF0Vdlf%5bs%8avW?WOieXe zUhc#7@8sg={}s=F;R(LwUGGB1##vlhDmoPx)n&P0tS>;(pvF}lg{x*P8d}erB7!Nm z?5;S@Dk`-@>Y%iWlwko|u?001=UV4WT@gu-0ANXe`>i=IQ(#=^+G{E|H&4MgQ`z|r z_vx4DQ6qI;+K+;AopKOr0OGeNk^>c9uVodC-&$4|K-X1Dgy5UdsNOpd$MKthIap$Q&-Cp^ zV>;vbXTF!&8)0mJ@EgsPqthLXHKZ;`iqt@BCJyo_U#N795{(Ok#WFPT)t0I=88J1} zK%yc_R*yFUWW5>M5>-auK@nTke3bS47!y@OQ#C>TmzSw`31$XpVaFdCo_hQye&z2z z#KzKo8Xbua-a-48x6;~oGjU`w;%F3}6U|7m){t1?tIw@*&%sHuGMgU)MauQTQ5wJr zQX~gEO~dsE8+c!u$58XB4N-6eGQ@f!`uI5KUMJOyiHv?#1b1@6P35{kW&?!Y?GK$T%+@;n^yR)p6 zlDXm9Qj4_9$hSn=&`D#JNu zxO|d5ufrP)PT3PhG!jccFG@A7FuA`;u0;_=(a{v|i8>ZtapYc#NX-XLB#^4ds9?o~ zi5mrPS*iFUt>jt30LBKak&;+)29cx)y$r@hWY!a@D?*N7NYm>zc;SzZ^2I;=L9*^F zooSkHe2DR5573yHAr_BFPHZG}CNP4}q1W}St)#4XJrL39*ibqh6%8OuI$J@+7RMSQ zyjH_-z+l*{0RIFS}IKS_ql?j4Z@6w(NdU=yg?xPb`n8T$qfN zD@q|o?(&zHqUj}pLas3D&H1{KYkQ6YxeFzV_l;o%4k?N^T?(a1y>qlt&)j#5UFVBV%wudg`k$I##&|F zW_&55*HSZER;uB{u0p&Zk|Y zNU7lPrG{5WdkYVH0u2LW@3mx@QBz9BhCFI%tRBpVd=bgk!b~q!M`T~N*RVq!Y#FaO z8CITE0)@+QheO#em$ZE;joPJjNvqKb1!#CJs`&`#L?z@n)D?wC+eCV*inW$TqgerP zk|dQ$d{9bAkxbiqTv;}ZVzVrx(P&_e-6ryNg_Mj-6i@^eM6=nfc;pg#x0&-}#q+9moFU_1|p|{@~wZ`p8X8j5p~-h6JIz;&|ctF6(Py znkSLP+L)wiNt#03u-HgwH6!l7d5lB5o7{Au$<$;VqC0^c6C`ILh9pjex7^;Lo!DV4 zTRy($*eb027jUZ1A$XcT9$){o3x7WFLlAVCsQHyf!@xwFyZl@fpbia zComSn!T>tgL)77;zx_MxpRnAzcZ#le3_N7HC-siLb7ani_hxu*e4)hf1<3fJ+N~%< zuE_}0*S6UON9&5#M#Tel=GTuHq>q~QRHei^%%7`-`@;6JVjhMUM_J-r)D~-vn$as5 z?9?|BI;5UQ43O%M5l(&e88r83Uig)0FfQ_%hpMmK!8Vay;1;71xmeixOYny%w*4Z) z?Ci`o3WjvVg{<_E*k0|BRo=X%7jK>ObuOzS?^5e~^5n^_%w(R??+-?(2dBf8!B3+>E36yAs?b2lHSoM; zgRe-0jkN|F%X`q=vgZ!ycR4uQAzmG@NRZh zMO#4%6bntc3n=N{QW@b#GFzLtLK#v&CH_}*LVdfdy44y|q`1U}8ep}<2v^PMHR;ij zEppi?#TT9?-sSkr;c`cwJ2IaI^P=2uiQiO}D3XyviAEzN+^iU^1WHBJGG1@fBSN>^ zs}f;#`Ph*ohk4jay?T9-q6S~yjv0I5J$zb6vje%mDf&)+%0SQ0e8P6Aiy zG(<%nB`^6Fr$}s(hTt|lInYakov;d3Vkb;mUluF{sUuXCG-@omc#_HMuH&xhR`)la z?xzS%Y@GAePaWk`zwr({W9_&7Bonv1k;FtaY)aOLbFXw6dZcMenr65(4_er5Bw)tGRkE$sYLPS= zL6B4ULs>$x(#*{6EoHAuODlA{-KuaxRq1p(TL3;N7osY%URhb$;%w#|m7i~^KW?>K z+k$Ve*Bgm^%e~BIyI!p=)K#}@eU++)tEGDj7rdbsnoTXJMuFJGF_Fj!&%2!xd8h~S+BKLo<>X+I`iA7}q<;8L(c!v=}f(Y-C6UF-*M0xf-D!}aM4 z-WQapG`T{+IqZZbUGwyEN9ui1%UX<_b6#@b5o1d5`t^*2@!YYNZ}l|_hQdOYH$ z12OxiB03E_R9O_VXG=&(SpzriPl&8k(W|fQ?POD$p;(wUU1=)d3R*@BkXAQL;yB7C z9HUl_Q6lWQF6Qh>PudGg0(E{QE7i|ovK_NtfIZOxi1zbyzx3~U-#!034<6mkXHL&C zNK>*rbYidtpX-cZ)RFn1?-d_Pg}rE@oh^W@(EbL)>>{yNb+6FTZWavNWtP=pQfZz1 zP)}SGUAV$jzkCBzl~KrLG?L0_=Zc}KV${@qd-m?%x8>L_UcAWi@)DiS zSOppvE?n4>Sc;<1QKM}CQ#VVm^TKOuOSIb)WLd`2(jx8F_TXFRd$m}Zv`o$rayMF! z7AoGr<6qUT-drW8x+wzQR%#|A1sEmZ)`7SF^R)-x58elBFT)qgJ0^fWm(_ptR-3p% z!q|1-bF3I*8={}xlL^l+bOTFK@pFZZVgVS&8=NgJ_yS06B52z%Sj;K&1HM?*gEwcv z(Yc}URdpy`$`)RNEa&^){Ca(Qsk=nqcs9;X@bQ29ZH!GOQ}6z1nlpQ_J||D%rPCXD z4>mDGrZ_nkj5T;CL=D5#M9iK^!ws_yc27k?rBTozw#cMcw2>*Rkiw#nyIL4!3)z?p zI{;tmlQr1Rl$m{nD%$nMC-VhhmD~M_%PN!}l{KRVuU#*O-3JoRpYDfpV%3m*E8Wjk znP?}R5eZjE4$Qof-+%mf`1t3(#CN>m9)94qqx{N~&y#w=7*AZJ#a!X5;XN2LY)fwn zpvnpmi;E9bKBl!2+_FMVb+pK#N{$d(qD!HR)}j(_sD1$TcG%Nzq`1M=5-hb$w$|Ff zZO*W7>##F?h991mdimn-UFZf=efCr)tNZMOq37z{XZ z;=~B>c<&&F zlvc*vMRKMDqRIo*p=!wIkJCA@hwpyFJ^q&8J~4;Q$N9rwc@x$q6L0+q+EX(mkz?(m zV|g*uv_}mgw#8O1wguP;Hyv(r{r(0sZNqpws`#KN4sFsz3UlU45CU-&j@#GNtX_ij zDlekevMHu1PN?&W#gKbPKi3gU+2Phqst9e^^}-M72pR4uJcWTx1iKC-%$-(x-7J8k z(<(%cA`?D{3)*bj*Mol@5B*R7D8i=+`LY$rUCH)Jxb zJPUpgAu0_tQ30}rKZB{sAH3G&c!h^UrB^OLvl+^=Du&e$yI89fm2_oS3p`j`ROF3r zEr-&sZP-NFwzQ2=a=O6Zyj=N)=Ih=Eu+1}v%d*g|CCkWM;6bx~PMQrEvr(xW&Ye5I#p5E+Q#Ll%85sX zqI{{ycf^=Sw};JYGoxFTC$HLKL$AOUSB7(}UsU6|%HEg$vK| z&bPgewdwKI^~H^pFMQ%C7tc;7Z}?#vvo~PHvvMJ2X~ARTpwYEuJ!-&Trp6-PbX$i` z(-2!rTqIVkfy4}zOe2QfCRA@F0w%U3Mrg-|*aSIRY(f>B)|cS2qM&MBH(ZTV=jl2} z?qHC67So)8Dl2`5Qzi2r=M1ihwp>9uxFXZ;RI4Jti9HFuR~%{T$_j~s2i9TI%@9_@ zqun(wGGc8k#I_usJTL1b=zEi6X3snVjjj+{92t1s4L#y^8FsK0IGX0Bn z=+5Px3p=?yBgWk=E<^@D2iBFU1fP?l~%jWSf_(`p4>T%%^{8Ogui*?v12?jJ;lcQMwJ{| zTwG>pX?eu=q3#`#Br$vU?yD3GPe1((y+JxsaUde}`za?*yg<9%VSRm_JkPgPJ7ihD zrM*y@RSU*tqhjB(rIhg=weqSB&9)%5T#jg}Ct>BX7+(Ty3A}aM;acQoUt4{j6+s$; zPYYv(s1!GjG-+uzqOc8jh4$9zrI)GF>A!X&a?0RXh$kcRwY2KNRPQjYwLV{3M87JA zbnP5$T;MOi|8MgTK7D?U`7;wd^~oF9a|;tUzmr%U-KCtR`4pSPnAl*A3CC=N@pi<` zhnw86FCi}6qZ(F-VnfS7WDJcc)RrW%kQkv8hu&PV2}!wzv7vZ?+QnA1h8(yda#ZFp zx_N_CNfBvT3l=64%f5CTw4Dl>Q~J&`aGq}FSj#-iX~y!v(@P!esV6NrQmD`%DcV{k)Ylh8(b+k%h1dIz~ezd9r6e;xM*F0s=8_>%J^wR9b6| z^dkGe@-zotdJ)MTL{V7fOQEwBh#UBJ!VBZ$oW6OMvyF(;y#d|RXBaEIar;?Dl;@bD zxurEB0hSJep7*Xh@8;8={?v#}?2?;y@7ce9KSz%qCC_tSeCZ`#K7E?;&KSm;N;@1H zX!iKzC;uC1>WJfnGiT25#1l_!Lp!8f|E3W{Y_a>Lw&-oG_S0)pa_N;pTeVhNy_I^{ z%(ssgrwl6qzrM7?mRA{}-xC_mZO)!-QGYl;6lzD0ccs7hpH8gu#gAQ&(&*gveWAoL^(@V$ zh=~vr+MruwQ7asnO}Oob7Bj5~BZ?72VuVI)7)uNj4a-<$Xhz}w(hksWI{3^xFlC<# zvl&*)vs;rU8@3jsk7j^hE8mlRf9+aRoF;9t7*=1KjT6-(LlS{4;m$+$(XSS0r4DR|^h=*@aq{WCa_n6+W{zUVFyR6U>UqxiY zn0FYp`%RKm)sb+Ek=ReSS49O}aJ9Q!l}(Nf>~6;FYD65HYLPj|^1w5e?My*U7mUFIEbN`HYUvymQ`}*|Ybvp7ncv#jvwC!!`TnICar3 z-hcBM9yzv2KB=JN4i}>4b+`|9z~LA1i@)~={EKh@M)vnJu0F7f+fFT$ScOrZ#Atl9 zlT1-K?IZIexVj;bN`*w5Q15-j8iF)BAPA~OT@~A~?Pa=XN_UGYO}DF>Ld{g?8}QHR zl;^Xl4ouS^wpM28h+V|t>%v=96vc#mGGUlcsMc1v=r7;Tr62kzj|~Q_CmKvjGSf#V z8DzacKO2saJZILH3{VV|W>!aYdZjRlL8S&v5({!1M(7Z-q9V_CPAQ(F4A03jn43rD z9S=-KJUTPWeditGrUxJ7^x6vJvZRQtA)O?=`1-G*z9o07X&+x{ zOq|u!=k0d8^}y{5TYNVThF(kW*s`RFpo+Leb>8{x;s~AOj84O@OR;&0_YP$YiB{bB zzy{~->#-`nRV&fb&ghVyWy^)3qxnv@JEhuixKiriv#?$9t?Eh8*poN0uyZFDz2HUp z$A0cNdaORSU@rS=(%w!qf$wT70+me^m4$t~6JC7z90N4-y@Wv)PVP!PT1>E~B>3=3 zv_SOnKRS9F)K;w8hwj%YKLu+yEDc3t+~PY0Q?{NYO=s6V<9wHP+J)1mqRppd)?J_5 zxZf`D%q5y{c=0|?ukPTHVhw#$zRz3W=qWxcQE=ZwP(#4OI#sEg$Ox;nY-CnxnJeMOt0G zJ<_$O!jwIZG{Ot(Jw}B==k(+|bn088QM~I~HMa2QEvgl8kbuhs`p^8$*^Kd6c7%K7u-a zoPIwe?e&?Noh6%@r8hfZkj$gA9zjBxfJs9fp*F`}o zvf}9G2B(Xh<*H<;45udrW1Ud-f|$T5)fg2b!tPzWIds8c9)0*BW@l%)@x~j^DizmX zfBjjHZ{3xv$Q)?h;@a@nI_O+PH|eP!hgODjChI-D+80GH?XX|8-qLArQ7U%a-MW}> zH{b5>sPolfU#$_$Ohq;`VsZv_g4i*Y6|yvBbQ$G%@c0BZuP}2NTk1n7X5KMc^(^ep zrb7m5tWPPj-6@k3Wgu57-BPezD2i3N4##fhE#LL+y#M%U%qQNvhu$JHmwp*Z;_0Oc ztE&Y{hi)h{urQZ$^+hwxW}4LKa54KB?}9=ZiJeni9dyv8c2jj#=SuaFo2j}Q|Jt** zlS(0-A)DZvl~+1eTcK9g#xX8Zu)1S{I$Ecl)z`!}Ux($^4!5;QO$RWaIGCADxZ=`9 zE;uyHmp*Sd_Z{EleRnOf;T+;@=(c8e!bLCUJ%95NZvDq^<9U}{$R#@$d2nTo#3(8k zK2@$Ym4jJ)G%X1XlDhO}y5rA)!i8k_P&gfJ_+?|b)5@J{3jo_JHqs8- z%bq0RA{{YkYhhyu8=K9GCEAemdJM7zOv0|28Myp1)T?g5UU~_ek33BN;6p5b>PE_k zA0ja+I!Va}8NInV=J)SsZqF{#+1-$)kfe=@tM=`+&IOZn=NJH&3}(2n*T;EBWi7e$ zRNi5o@MvDK>^v*IjP*1&JwSQ!@PVEw5-b9giv`Y~yYYRh?Qz^$2BL$Cg?AcyP}_9A+u^=nc-WzELeB_Q zU+^QLr)r}B!I-$$M;K%ky}3=+kA^f%Ck@r43A98g)+~uuFi0DmHqdm0<)dS?F)Zxq zHAW}3&bpQrC-VlgqOdtFp4ue&Cb$zff-$`IRj+bC^@qQkFVYYARWriKIX&j_PwOarJYqVP$odJMX;noLm3AC^)mcL4Rhz&TJ-fqdl&&kppc>sw$Y=Xs9fKbKW^; zx3hlqPYV3?+p`_Ed>5(%JJcguw@v3;mSvl^`~@q%|K%n)_!KhSM1+~$1q-{*@X#%S z1TM)&VHph4b_*Rp`^KpPv*@88s6UgDWplYgCXPMN8f+6C-N80>fFJB*F7h^`+}4A$ zV`S+zuDR|yycd1s&;M|S1J6_a1J`22V@!%I9GTH%X;86QFU)9-Rvs@Q4qBYxv_cXU zwnN^86bV)PCiQ-68`hKF+EUzm>G+;Xvt??E z=~*TuT{G(G_fjYNO2lZyGv&%&`P!B<{OtUl1OD-g5A*2Cgb&?)nj@!1xC>svJKytO ze*9bB%EkNl@{)`9bJHWI=|`?u7L!G6G|+LPQObw(<>>cdjUuT_sVCC$LTnz&AvNA7 z05Rs3*^3gIrO`~&v{A8?i&7hDqB;f0~10~8#3c>y$$)?%_tEP6uYmw zg54Ki3|C!=TU=zaxj|W3ipo+}C7UNsQQmqhObUAQbC`uWlG$1GV1Q0jq@RV?(F$Kh zCprm~Ial8aX)`-)&Z6^N4pwwG@>%iudZ};K z7^p7R+sV_bt8eYa(WRSx;W6JASHb|A^y173Mdc}HJgEdwNjrsjhcOCmD*B6OP>Hbd z5cKwQVs*^HT{BzuZQ+mMnG%bTTZgRzfmhEM(wUI)6y`FjDK1KA!ZDM1jhQjHu?;I% zJ!F}z!1y@U?&b%7=4TY?yqdk@>&c?m?jY3^d6=zx+9`XQ>gbHs#-}#L(iyz=p>xw= zMNAMxxquhe&-K!#1gmKubA`uuAO-1Su~MU;brxI8aqw)xaT_Q?rd`nd_Cq;gi$x-` zMNJeuWxMpi)`UC=Gpu;_&1by%+CwaD=G=CEpHKYGXZWr6{uTf7yS|w(J-naK9bKmI z;UX^MyH8zUl4u?HWD39tTEf~KLW*OWKt0S-5jplQ7z?2y=Qnnu-Ht0NnpnGB1XJ%$m^Rtj;NG}b-oZ75OLNvYgVfX61XZ83AlFd!_ zd(WOKCR`|RM&os0Mm#zgurxDZb#9i&_U`0?V<(xL=~Lmc-cq@eGSA6-6Vg0I*B!18 zn6LLeB=e+_BF>|e1XBZRdW#zEDc#gG&9|Wxs*X^i3q6D=$IzwQZF#<5T&mf499D+j zIlyjTDGnTdj3b|}Sbo58{)L=Avw^;R?>4lCh_+c5q>=Gp^k$PF(N)_GoNEqesE>n# zDZ~3MQ&TC^gWe){-+3>j1JdVxA4z`~3c;vgS?MfTAHpQP7qAL1K6ZSBU`y}OPT{6%(sS}&^QF~+UA?=)$Ci@2gYsH@ouvIS0% zspGSiy4LNg!}sQ-R({2Jq6j(X!D7%b0*`}&pC{{;mi2N-+wo6|JJv1 z@m!Dd76zQ&7&Woqz6&);?cEw`@IbqxWT%V)J)V?P1=Tc0NGH-db4?hAQ|2RfG3rWh3E3bste-)g69%a^J zc-P&med5zpCr@F=Bb0UFeCYMi{S49`u9srt_F6=6RmsZ358)B2SeP}C3MAH(DM)b~ z&MSH=8>GsU9f4!>^DHkc^4Ot+tQ|U!N*N~TVB}sq^m$Je{h@n3iyL^-%#o!jX$-+l zjKM`K(}tz+H2Yl$$ug$iT`P6gH=AJ6(i;e^BsI`t&R z;Y)?+V6cl7p7YWN`NW@AtUuc4A`c_WXjFy_Hu2LWyKd!(m9{sVG}vx4O6qX&RA;+j z`iB0#9{K4q40IAhGQXSN^S>WgZK7w-qc<}{raf7z$r3}BYR1Yn!yX?y?8@rKsa$+} zgscn0BJYGsses+8kk(+Rx4#Ni4+ebaV)D{phpi=~wA^0b4vMz=3`l3OnU+wu{sG}4 ztev7d@IuWH@NpAfd;hx5kV$l~XN*{2ZFF9LCB97?nu3c^P&vm7Uib<={l0hei|_gq ze&TI!;Y%+*!0+931W7a|zL7$y5Ub=OGfIbuc_jglZPO=Xx`u9fySl^gtzF)l2=~rp zEXJRPpEb;hNElX~ZC0ldZMfztPVNkpWyzV;LsO<~xlb?at-C?Bk-FYW++wCY3Qs>!q^_A?p={@=wsS<{R8O|Iyz?qBAXZ4cHSij^lipnuRQ&LqGv;7ih9sNNc=?CZ| zO~UsO5|tzUT=UeA;ntgJ#S~Kl<1Nj#jh%1Z?bgFK@BK5#b_kVDcyl~EKV1sDE`$7- zAc{G=IZeIq7I0fTh{b}cd{78_)he~5?Ko>(T57+_hQ=pN9MO*5`?%`mujL)T z^k4ajx4nfg*}sGL_Ir%;0+&IFB8&KLB|;j76i)l52%0$1@`T++y&$QWaoqYas5VZ# zGiNlko|>YfhOm((`zfJDJ3(k8h@~D}y0(}PM`I>M&Y+jEFgFv`l;Qgq7eR-(4Yw|s z=9E=QH6C;D$Ps4G6{=V{)OF8A?b?aI^DgAk$KnZZP-&!$ZFfm-RbOqbIZt@)tB_S@ zOvC<=haZ859&Qfg+`GeHiSqxnYhUQLcsk%Pr=YKp$w07fYf>Xj)Z-?5UurqM$s@%Rfm~R6&RKl(# zY|16`NFGZ5#CX(5FsTRaG1jwuH09YZ`v`ygr`It$1B>Tzd}+dE2N%vFvTi*-CQC4x zZYWn$2QYT=2epM_Vl@4IDfwd(lO+O9W_0w?H37I!j3PB*)!e%)0R}9sga}~g6ltoW z`&yfD>l`@aP)_4wXREyF$f9F%Y;6$Jfb>#pL$=@aNN3?;dKkwR2tm&A|LCqduG96p zHWu4&khwzu-y z%MbB;w;Un!35ZaNN0bV6MM+?|NQ|G?t&x&ef<=+|=^#nf%)3-$TodSlfyLmJCkVGy zlRQyF6t3p@dDrP2w}~F!Mz7+;Gt6`HGG~5vo1r_10VPdC{C7wxbR+N|A~p1(H~J z4UnLsU8Z7CmRx-FC>Q_fd${kud-?0H{zuqdyQyr2s}*Ij!1X#5F_W0UXz@Y%{J0>4 z+P=q>jzNdo8e_4A>D%>o=q7=DVPJ=MA-n~6`_)1pcsGzQ3g)CBMo1G+nuW<#Upc&r zu!C}p*Aia-<~#WKA9>b}Y7U7*734X)lMh>E>w9z3>J2kBQYaM5{vEy(%uTxGf+ZEw1@*-1d<8_gh7}cY_!s#vl z*2j)nwSKAf_FO?H*RDREzA>|Qkv~}v9wk;h-!Lg?yTds6)Ui8tRlz0CzLvcgT*{yP z&4>AsZ~A&}IDa>PaN9$a7PL`R5&lSH`lodgB3S~Vfl9Rr%NoHd9kKZi%fQ!1B!1ss z+8jGkjR&&oe0bLpM+&;>%Bt-yLz(s(iJp{!kpZl(ZIJhR3Kw7f#8 zpszG@CKUT%VXcC25>QEsP7|(q=n?k)$shASUh`^}44Qv^`MOqv&{to8bg&Mlp?-$lm^1s{!-*cTXw^JB6D0)7q(I=psXW)f`FwQpO zQQqUVV|6Lxz_b2}^+&!EUIcx`nf07Qd(vi7-{H15Kx2}IBwrnsVk%;!s+&q6twDM4 zWez{@A50lN0R83YC@H8!T3PDjtm5c zQ3juYwqSIS7}pvr)!C{lHLYG#>KyEa4rf=oo)0xVV^x4WTqkC``8|#flWz(}pI+~y z44Yb=7dpr~#tD4b#n;0&*NNH@X@-&#f#vn}6<+<1zJ)*dm4CsfZoiXPUHe?#^y~}y zqgx&z>4gD_R)K4Z2|0ZVp^DBIiMT1+oNoSJE6-Yw3>8<#;2*B&T8lNF8eKVBOiZYU zol`-=wV}*eQot!jQ~0Zai7m)CN-~qs?}dS|Zx|0AYaJ%fr()-y7FN4^2fX~H;a#aSWCe^@fE!R8g9Bsv?C0U84byTfdd)&@d{%?$fZ_+E|SDfIEjfA!AK{xl-@>T92KD&DT(XY7N(YD8NRQPu@M^(bE;16Gu^c$`Z;_7#EI7={U7oanH$| zrOk@cDGH~s7OK)yR*upNm4iy4La_J{+3tlZ#_3pzkK3T+se>e}kB8NTd*uZyP!W_^ zo_40`BYtb6kFkr%#ld4iQAr4wwl3n<@%Psur7j9LyoA?qb>^K4!z3FqQy&TUK8h}y ze=jj6-Uk}kv!DNR-u0Is;Pl!idwYh9cFni3^a2G!;lsZvV{E;%?fdqq=5>4xbwnYH zAlOKMbE1PWJih%*Qup)$yZ4bnBL3VJaNAw#S%G=m|7+)irQ@m7D{Kx&xFSe*m3tBw zp!)nSyD#cZDeTQRw?4y6vy!In_4a)*12ePeBn_9ewfOZls$cyz>}y}grF-`9vj6&P zEL?jn_bbiamB(fYD$7u553&q;DQS}M6{k;=uC6dzTW2`jWIP&CPIAhkpsH-Z+^ryT z?k?1y<6f5n)x~?K!Yrn{;Tme#`sWvl=PSGm`0#bYCcvggU!qXfGn~L6ad-n>xv+nn zwG;wcSrCKwjvWWr*?ry-9{%7#E_k8BOgOzV;rxBGZI{;df%FbLwqe+#b>mYIy5Fe$ zuUDCvGSLcx6b6UZnz{2+Mh{iNNh`*>tJvu(f_$;I6%_H@ceG%2=;>#$Ir7}`@EENX zX{zaG22oHtY1C_l!1b5(SQwbVnKcS6Dx^9j4y6oA!d8fyGPli8M^r7J>3BJajzkIH zvOy$3>9sj{eUf>l@DdeYqB__tQxvIY2-ivMo#Fu36g3~yE>!oixIp4tWnN@PsHVVq zF1Y;peB#f3hhP2UKjUY<^P73e1^c+^z058>uBQZ zTuoWhwSQ8?_skR<$VsI9#l=I_kwOZ@g`%!iOIK7{fz_>0rIVOkvme!z$)wsMVQx5s zj3z_!@t8?fFz@B@8(U+LTW&-B^@m{Z9=Pa2*s<`;AP3tyusvb>Wm;~qHo+63{vC1D{EXbm}R!#!}l^&nnAC}4yE{p0Bqd1`v@vLtgm^NgQ4)r`Po2 zx)ia{0{sPp-{~k;quR_jQ=N9{i+jV$3B%l>l^|k*;xcZJo%c+NIP5bD6(S!F~$*mRM@q&Dj$Uy4Ghsxr(|kS*9PEp zpos2C)|iUZO0gUN0^g*8)I$t$4IRBrTaM2`qOou+#Qm9hF1h{&-tmjS%Fle~H}m2H z3;cG!&oIv;lps`bNM_=9n5a-VwO9D4dP{s*BI^Vb6LELd&RWCRC1TDsa$6st`U9&D zk5}TbZ)@Wj5$>)(dY6LVV9jk4>>56@DBtOdgh^4bRuybM?iFW{d+$Tu@w3e~TMcI6 zqQj^k`w=+*z|-hXx3CwU3dUR(nD74XfFs>?H*(W0NHIb62FP#&_sFB$7v6IfV2Sh~ z?}FjgLW*A43A=Wp=I7AaYvInjsNVnAxS#zwcE9>HyzoE&GB{8g%@L~mt1bhu_U+#n)N zn)Bx~aMMrj9oDz|;kJu}Stkr^`*`AoB+8h6f*B zCsFM{Wr}ZAI3^pmSw-)JDo)-jA0BId z7XQDbey-}sJ6ji07q{WQ-u^exfpk)i+s?Me*CmE+RsuG%9IW`jTdnD4BF)y_5xQYV zfI^(F!yzTUhe*2uF1zMSKpFnsfBz!@p1W@clgd*fls<%oR`I&5kAsUab0tC*KUWQd z*yb1oRzwwz*K25^fnTDXrLCcdI5V&{p1lozPyr=TeLJgV|Jab|HAUUEIW_3PnZ z{$Hp+|6}wYy$km4MvgoHwt8wW85v6|;%QO}Vl7ftVd3oeQD5;|vVZ+Em>oN@|MP#+ z|FvJ^h5zPfvBq%M{SSnNy4Qm&g{%)*j~C=)_HC{)%tuV}33;AV6(wb5u{J|Dcz0UZp@A8u{G9b!utOab`uVz=>2sVM+lQ>}IxMUpOstSw!6pt@I7%!=1%W$M z;0h=Ulqx|L>^!)^6)*Z9OxEG_J+Qk6dBy4VA`H>$3eo_vLbW`>mNmyO#-96tt9jqq zCZe`ToFhd_g}#3N!l;4Ps+~f6Jr$nlJzR3$fJ@Hnk!jTgDhev(%~=mFybp2h;IHv6|cx?hbN++$(aYn7HsBa}R}6Q(H+jHFb+Cs*;xb*_xYn zm=LW}&c&f)IDh<NW@ zeuK8AJJxJ5i4M2LIaFTaPM?12`**!P873wQ50b2R0(-~MS9cHPUd8$nsv)noI_7-t(^d}DBd2MW*VR1tv@X!+St}wWZ+oH&$cHT9w?Zt)n;9^%>7jfSyxIT2mb%C#x4~150UxPRRb5ZFfp0ct$ z|I6OOZFk(k-`;T#8SuI*&!^HoNC#9V37x(Vr%&Oa@Dd8ACHy^h|27i#a|w^-V#vi% zx?s@l@L@6YJ`OKEwghYAF>&Glt9WeAYW|FxW`ekIJ~_Wlr`z?{@Y8F)mJV*drSJ5> zCa~;1%Yo!~mXAQo0pAX$NKgxlsL=$u;|}~^ydVGX{{1sJP&>y!@%{&(sGeHKTYr9M z!@sR6q$-iBggl2bM->I+Mes7t3-ot+c7Lu{<9LVqU2 zO%)ZcD!5={13els$;Xs=K~WUAm<}Dz!EnuZ=UX$ZC%D%`k$OR{JKy^M&D6lnPYv6; z|NnI4#rg>guSiQ=J&_Bqzgl3ohk1`0c+9}#w4+x#ymnY+>17r%73d<|rC@kQc<~$G z&H6*Ib^_)#XwUkv0P!IarK{Re;MWU^<*MBtMuNZZFd=-M(N)vJB1q{}YpgzZQHCDq zxJ?Ext%*;)%?!L=$4r)R&4q&ytQjZh^=kx4ZP+t=7cQdHo3@EAaxQePUIbT#ZZq&d z+^I<>x^*OO?$) zf01-y7ya2?By&5_vkORn0iVuMnLcAuHm$Hx!lqS>tT3^PQ5id63lnG9^oq?&F?5Pi zWf&HUu_#6eV^QP|CSC>TrwqeVWx_?JwQj^YY|Z}LN=MovPn4E()?PtVVX)_tDFoQ* zloOWYV)s<&XQC9MHIir~edg1@&U(F#^`}RPiv`Ww3i1+)3N@*aJV%WSWSk@8NigTy z9KvXbdh?qwS3R5ZoxjDN-8cSn0xAinw zbZK)gz4q1o(fj|JNo6^|FI=&6j`czqRj^tLYtAro2_xxICIehHgC8tlW_ObA*pHgq zflKEp)GT9qls01?$N6E(_+_RYACu7J*sPPyXj$k;1^$jRWl5hJabdn;!{d-*g z4R6MlB@cb_(_tr>7x1c@LAcVZX|QGGY3zqJl)FL2ywkW`jn+wPraDwCGyFg`6!#R-Mg(uHDt>Bw=jL ze`}m*=nUMZ#RWb}b#XLi+ENc0eQUJi>w%>gl#6m?7mjhu!U!(x`wm~pJsZtlEs!pI2{dAeRSNwkVoO3{x>qf`W=GE8PjGKJ9*lZ`Nqa%F&4T!B;$ zZA_FSM;Ej>ve5CI(6OU$C`D?ExT4efsx1PydKYElUyP4Rz4)E^+FsJr%nF`tZ5sj`2{+++m8uW+{+l9p~-s&

L8Hx?32;LujXief!suq$hDg6J?O3$P&{bU3IM|nKoN*=xj1IsB|_uy3wXY zd%6?c_(AHWVUW zoNd^2YmwHdEo~;6ONw<;hIkhqx?JzaAjeVLmJYSl}X}|Pe*TPAJ`OS z1(O{0g6F|s`~~uX4{_PszJX8v*iW*4{1~%47I9^PbHc2z5D!)5D6PX)4p+t5i3mDc zz3M6GX#2f>$JDIbRbjReMSShL=ck>_zVHLLbKdTUg;H49EA;EJegr*L;?X@DjCORq zD~H0e@@UF;|NMWaKYKS1{xy^vaQ*-aOX-5mppIrZn%e&rMyDpYk&Reg5KHJLyQ}4{ zb`Xiq^g13#qnN*_7fo{_l}cPQuyR!>(v^!a1H#@N87>a$#J4M`#G#XSp5=|ebg&Jy z5bTbvlkm9n(VkMURd|l8abjAwLQS8m3vbxwN2p_K;f+PM^ss4RX<|7wDOt)ZRtn2n z=^0fnO#CCe!o~rm_h7xEiYmA&(#5Kp?o})ETsy?`K2puZMT}Y^Ga?KOtB5IE7tGNe znoC~zE2Z`Bl_tkRHFGYnwobvj%!USU36;1T!P0q( z$QGDjifH`gZ=0KAR)-VvvMpjysRWuW1#n>=u6;iI`2UH1=dYvx`hDns{H-DB_{rSr z!GU`kTWjy}`2CvvUtScI;s1;sOh^vnVZCSK%afzv7~AiSKY3w%pH>xYe~c4A+e6&H*A0tXlwq zKz_fr!&c1qnWvhay^4jm^OiSR)koacde2NxVRYP9Mp{{o|9S#TVKjD> zB`ur41>UQ5?Y*igX_4h%ZRE_>jDa|j_fghsRId_tZ#>MG0DT%1voAuH3iMb_ihzfT;wPR zY`V0L{=y!f`?~MpFaG+2{Mw%edekq!{9^h8D2>OZ4wqVdYO$F`Wezv+xV}aD4hA4S z!S|p{VXPD*QLH<~YN=Q&6f1?|Os-fi6eIeK^#XZs2W5W;MY_m{0UH)JD%GUa(k;Ld zCYr`$m?oUblqx;`y<=vNJPFMA$yq<@sCgdcV^+rnYvVDS5xZAUw*%dJ+r4{x40(#~ z;GmacZg@G#m%S2w_%IZcjy>L!yg7@Xj9_zwjEBhP5Qal!xQVQ7z}hB$eGOS(N7grC z*AA%0@VQ&q|Kb-iTwR6!0J1*%y#4IC>~b!=@DK}&bIhi_;HeP1-VRf>nOB_6(e{lk zsHdGFZ5_C&D7;Xrt8lYThFiA~e_{Q6&*uFK-?%A!xG%gI`1HhMN+>pjg$0XNma-5` zR*@8fQ&lisHwCx==*{o6T>sU-&By=f`$_uqT={w^*RdM~W}!!t8K&FWP?5&RMx=-5 z(1}8HG~AJPdo3n#bG>mGQFwHt79+}Ud;2r04NtY^P)VIY>;rzO45C%cLKEhChAdU& zMFf0(kiWJ)lA^2}l^2{#oa?K zU#1hp>B@@5Lzi>G%f6N$`G5Wculv#$bJ5=2{LnRr__@y=4Z)c@n6nrO@?Y%@nBe)V zl?yPO4p4+vE=rRFEa82;aN@vIH#9LtWfN4A;B^l#bJ%2^e(W|{s+UAoOq(06qapiQ z_=9vH3h5d_g*~o3-3{b9w*PezuB=cw#l#0@+?*16pLcTP6Te$^-g#ty@`rHE)oqvd z?)OlB%eOU|a!>kZEq*u-g3_8N?;J9)P!^E)1ALN&08`Q#{B5_fJ_36*ztwrzO zOYf>@GdO&Rs}3Dvd1a0DwM}%Ig~rKGXBmn}@N-d*BOQ~@SJ+87ky_VSKW17LV>deJ ziyIxk-R;wt33CsHv$S+!ve7f)Dk&?6*NO~FslX|VG77It3<=l0{tlLozK47M_N^pI z#wD)?H^Hq=aD$9=K1C~yNh02+!RMawnF(_T22BF)6j9g6&LwoWb&AEt>72pN1e2LIVIyKB;wAX(#>Gw{SXXlB*{|UE19$L-ANp}_{=MJe zjpq&c_=yERc5)-em1}$g$|ebzLq9N5aBRd1*bC?CF6}3Z1~eCNF$susA=O` zp}b<0XVJ5wt73}fRCPx%O>Q)u0BWX<(84jP9WTUhD+hhXNC%>8dAYC>&E{vdqEEm$ zKR;H&_K<>k=dZ&>ho>KW^Bc*oy#;&SOY!5;xeKuKXUDQjfggovdc4Qm5>-~v?<2h; z6ke0Sa12Hx4?WD_WiJcHKF-mbnIYM~hu-zqa=tNCrDZ&x#P7njic9Zf5T@#k)VAM+ z`1(J!-}5#JAvFifcBp1`j8GT%{UQ`!!kh zV>EWPVl0}u{kpCY6!{D)4wnLlDzT^i@;e<6=cegDaeeXuR~Yv zN>4GgB(nxdLV|#=3Cpy&s*bW`BO$t8E5kLf`X+99=R5e;pZ>S}_RszV|MI&1eB%cv zJh+jw*i#q>7$5B?T{Mi>7$s4&sDkH%2B$+;6A2p8&m;ISC>4DR3{Hhz5wNSx=XI#-iE+XJoA$YGt={;r!**&V@F{$ zI{OD(3vYe90PQ!Bn9GI;y5Yr_e1g(puGs`NpnU_HsK zqZ~VY6#_PmQz&gA0WB3r@9OjFw|0-DMIR!8QWZqw~%Ezbf2-X>DuK?pud=)F6l9RuxzZXBqkV0$fpU( z5CXgAGfpoTagrWVF;oL<3FEP2rf)(zgAc3=6)#>F6C1n^lQI=2g{tPJ`GERsq44^MOUA<7fxM$SPj%9FX>eob+BuPUA@{<_hOwV_f|1~Tkquo? z`5@@p$}d*k8m3Uu@=`@5pb|k@A(`LJ6>s_>e&^l)o@ZZrF+cIGZ{;^%brG-rvwO&$ zBa4PeNjxi6{G3|Vma7`4L#L;7=u?t7)>gQfX6qUQY#P>L_`TKzCWLf2v(ve^vi;Vy zYq;K>pv#u6E4np){UY3Izu>&GRHGcD6go}ljf>D+Oy$*`<8rPF{DTkiIKG?P-@fuI z(1#D>AANNCJ@tDezwztvHD4WyX}$ZXEUc|#e*X_Cf9S_Tk*rE|*+=>%WO)c1O_D(C zQW|&iBzVD91->kqJp6FTihJ}i?!5k5%3+Cho>UZBKZ{GBI_L6tuE!)oOGa%;WI{!| zN!4_|-RPjLaYA1_*g>hVzPyr^Nvd4=k0275Giqa$kR#GBXn?`%J^EW8Sik1mc$ zQHb*lbI&enj90}IT1Qu~$bE^)ygn8XY2713Y<-iOAq|e$zzRwllb@Qud(OfO(?!;M zT*Q=pm$Qq+XtU^E))A%DKdYtJkFJ-D9+@y}Vc$4mu9ro{A1zJ8he0F0!^e)Dg`E`4 zUvL$BuX{cJ>X-iuuYbi$x%#3D_%|=Ph_`?8;b^xZSS6%ZV+c6BmUz6y;UceF*SL0Y z*$mxqG*Va5d|l}PV<;UkY9~TgUsg5rQ#%24aEG(zNg{CqJ}rflt-)`#uYf$|=JOf3 zIT4(7(qM!<)b6 z?BA0nuy;57;P;b#<6E#-JP$wI#5)URj!t{wIVNc(r#|Ta-6Y2ihgg#^`Rq-M72G{H z&urFbetrR`3|X%qO|N`MvZ|j%FFB1Vbr-89+*P$0T;Taujm*F6YNoY*${nzN>P5FR z4t`fW15f0_w_WDBD)%H8cvSAuvmRHtFw!=Ta$pgu=qDxGm$`)rCZDLj+P zp@7sV>_k|4Ea%k21#4$Y77h={vJfF;6d0{T1h%UGF66n8>hM^iJi4L&3P!``Mj2QR z_0Y$wh{MKAj+jH`W6Z1fs@bj9s=3;$!PNsZ-tazOtyA@Mk5d24=ZQRU3ecmj=ks#iut|p+rNAEYrN*wQ-xPwd@=mU|MA3c zZtotFkNpjL$Ij3JR26oM!$h0CenmiYz>~ul# z?tPRl+nNR!hhq{fQ3Rr(ayam_n}Z%5u-Q8V2nYl1f6KK(oH(t*@FIPyc6ktHQU5D1vid#^fq~X zPsDCeTOw=JGhdkNR;dtJALXo%3q}>JKlCVT4?l)Y%(S4Od;9%A#GgEgN)7fM|N86? z_R249-?J-8-u6vT{*7II70I`K(-uXX3w{t))hauE*xLE3WU{i%=+sH7)iqW|n+!*z zz&WQ)0dKrF{5Bly$F-EKNv*mO7API-hlxxCIb}1RMR&$IFBoX706P9t3Zqin1UpD>v_Q|Z{-CqKf+@V@8YAs z_buG>r&kAQqzZv)SG*DW&xXw$MC06q6V|bNG{YoS?N)!vKqVZc#29wZXY4z#Pp_XM z7ScJGyC`Gwunm-_=&q&{C~`qN(21rtFsCv5i9`&oSXnPQ*zX18Q97?%1NY()r<*;E z=r##Lv~20NBEQhI%BrT4M!>I~xVI)ib>Wp*cq^}3XSV1-R`XQ7_@*E?e!!7$CQe$v z+{)vZi*ORUupRX_LzFNn9g}g%DQ&oL=OB1zh=R(aYF0u7pVfMSnKdNy2iW)Gui-uK zdpE!NvX}53Z}=*H@Hyx4uWovjgFKYLd+FCWIBZgwyRWsm|E+<@E!=woqp?@aJ=i}1Ro)TjscJ9_$ zM(Z1l#j)ywc5BbZkgZu7{-b{z>5vLGhVZFR!AQtDf9=^X{NN+--`)wY zel^_ivgx1wlkY+Q&hO*b*UtUMOB)15RfVl8#5$5R!8_X_QwVGk$4F%H>PGWWIm>!j+bgBo~G>#uIp@5(Id%pm0XvohLQH<9%jV#>~Ei#48f5NK6RZ zw1uUgD*76FM(7(&PeZB$8=$8Yg9L^fF2vQx!Je*p6E#k$ROqIoO{kVO+%XXSRg<<$ zV`bTXQ&J}&do{(6szVwYgLxlDPu8pE1oNG+(lC_vZAaHY6tz`njTr|FE#D{uC5gaD zpcJHV9S$70%JSf;4c3d$4OHDMI`12yRCOk&s&e*R`AX6~7xIh0_lJy2%RjknksZC1 zyb2v~2^2Oe1%2(f=CLkxy}=_n{N38twsZ2TlkALqVYm*R!JnS$lAn4}F0I9N25cV1=6J%fGwYlB7-hDTP z3hGB&6!6d^)4%B8{^>Gu$;D?Ew;%Xn%76W{l&^Xv{KsE8i#4J4?0Hlk74aj4>ohf-&Z4K@Xo_@fDf= z_J5_m9QS&-$a8t=S;{?LS1g)}ykC+@iLwQ*$SIki`V*3wG1iZ8*(RDzU<0q$L2qF5 zO->wf>^pRfD_?deFZ+g1@=ZVU+w|toaOA^bb?hgwGlhM9*qt)WJZs9bJkA+z7L?

kNQOU|?Fhx%h<`1Rx=1jnLwZi3|VqB4GNVOuJ5{j!9VvKzl_Gw4^b3%NM5!LuXD3NC7)`WgW_qBSO-VD>aD#}`7 z9Zk^d6pEUH0O}Acq>8KW<6r1F)H!VgV>%f+E|HNez#F|wr{ninP+8f z%*pi$N@_Fsfc=(LPF0rJVMR5zOg1+-aQ$1j^`86rz-Mj(;K!eTi1o4x&TrADxAdOe zd2$=^VDBky3q2GPi>s?K;%K!gK~-2v_?eBAGovExV06It6E)Q#nz{q(Xw|zNP(9G2 zmcMrwL6I*2)9h(g+r4?DPhw1m)g|1FM zk0+v6Ewi_okuN3>~mhmI8~$Qf*pHUeZrWdPRwo603p_uy#3Gp;}#6#U7FQdVO2SbT|L+_L*YD28xz(iVFk;r=k%!Hg<*Y9kq$ql(zH54h&@IOzoM}a-ps!%KUQ&)MnUcskja{$`U3X1~(@{lZ z>h9QuFWr{Yz zt%Px%CEv+VUoRuE*TsQ^Z}9GNGw1l~1ZP7!b1_=wnH9AmY16am90=o4;C-t zy&w800N3rGVb3577MrniwH@Dx!usIV5L2D2rrQnADMUhCk&_tT6#P1D6h)|*!)J^^ z>u}Koj&RJ^Yo&WpW-1+%|C+@@4doDp3k009ej3f7iO2K_!+ z(&7S5?bGqJQSHUyyV#VaE-i2;vFNHAqpg*&m_}->gEu5L(!J`hM!av6KP1x3 z>XTLo@!NGGM-*<@Q$;HetOExjm=K?uz}~E@{0LpKfFI^86%{*Xd-$~_*gS@Fmdbn7 z$YZo1GoHEqm-6nv`3S%CFa8&7|B#+GQ&tFA=EHyz+v z8~6B~nJgvk_c2+YwAVWe+!5cw5IW9)jS_Y1@9)qe8V5=pim30-QQ}~HIG%8Fc@=9N zNs=^$SL?u@dAgnNGh>P0upd-M8-#Ej!sapI!+SL!z8Y9|=yBMqdo+m?Mwz1L1xYjx zj}ncqgk(ljyrSAG9G`LkcB_{@KP1^b>23x|S9j{?NPWEF-d zVf8VnhK_}O8;sWG?IX7)T>f%$n5+^+8BDZNv!&yNb7Tnpb;s-=VbDwHrJ+z( zUOGjRxZMfuf2sl>aQSq}z5~JMSG+Vv&Ei8Mv#u+u_A!-Lg~Dgz;WTZuj3*QriR=cP zG^QUaI<46V9;+xxcF_;R#g}n@t2cp!f7@w+>!MqqrNvqaL)(6(H3_&b#;ZkW-RG0K zZEk-q0>gqe3+nX4(95XuRh$(R4!>HGI2i2Q!_wbA%x(AH$MsiU&gHvixNT(=DR6m~4hwen#G6xD4ShX$-tO{%epei2HA&)4vNUEGOijW#Jpg$=4Qace1U!KTyywvAjU z>RyFGjwclG8lg~Hqm2%}6DA?PK!F&h2WfPc7;`C$;*P#BxgLygUZddnS3={XxGM& zet#Zfp{)yV4L$tbc{^+^!rLj}PUOP(0KZ!b3;Tp}1-uo=V8$p$g=L^Lw$w->=&C{r zjff@{kFCPgUx~t-;MiG7!ovI}U;9Jvma$CiitFp#NDy0y4<_O+ZhspBf9%AX4U-Z?X_WUk zrSQtch@&7-Fou|47N-72JXQzWN0kIKASD4CRZ>QrDg{%ZQaTlan3^{a&^O#w|tqjAz-E)AS|E)jZUH|r{xni*&GV2IAr(vVu@Y;k0 z%R>@HHA?OH-~IweRPuHFqF}B!sh0f(Ih7|`mEz07V>aSCPq&cUfn6ie|N>VF1R5?O`*1U zKzt)`9l|@;JR4sk+~9-{TY(YG?}lPVC?=YL_IRzy#nDI8GnPsjQb{P11ef(la*fv+ ziWJc~gZUoMyJ3@8zwLAAe#V_2y#VYC3wz-3RRx>t^K6`1K)D&#S9YRAa6u5E5K4kz z!YdnMLm=$sb;JU98GHeL;u)6}4~#03RIzIzW3Hdj&kV){EH;gX+dlTIKKGOpLw{iK zPGP#!@-6UKGnDlbyNIwjBr2p#1ch2y)qUf>6;6@h0~ESo8*m4diwJ=elIS4fYgL$! zh02CCuyih3YWf&FDwG>#Fw<=a@8=jFyQp$Vbu)Ae_X z3Xi3}_KG%`L`Bp<&|9E^t@xd)x1o&zQuv?RO#)Qxdc)9a^Sp!jBlqGH6Z}0=^^_7W z9v5Org-J zp{l^8f{x<;a-ukKB*oKX=blxRo&WCJUk`t}L?#}ShV$pl>V#z-c%xaGFw-{-dI__AO)pCj6N*GRw#*I;MsJtY6~TU5 zY*(~u$oygYD$(1(H_fG0UB{+4@QH;ctwjN`vr|eT2DDYU*ztMgu_Bb#Q`Hg9v8z-r zxW2iyYFa8(&(vzFQ#@(6@?#sI#0eyQIQk*z&v9`7UQV18$P02`;pPm3GbPC$Lo%~S z@xUFd4aZ!$bC#ab0@ zybhExL3SRkMRlBfiw@L0?Z6uaHY|cV(wa0Htz9(!*D;jS1doa>sW=5%0of;Qi-U zfS&}T0q@b5T!J`9b^Hv~@#E;j7x4Guw)L}%pl4yZtz&Olij zH7CCDm2DDPbTcs7%A0O^0@4gr)Kql*StMydMXa}r`5I%s$leZ5jfU1{wA^*I;~fYe zzAOZAUY&Ucx#F{ZFo`0`1Yc>&8NnACCqilz&Lk+EQsL-h$Wn_?QHcXmM0+4bi9>up z81qJkt+e)>C@Byn^yY(U4i+Mh)I_74M(f~02dN{2!t6~bPnL`}O43v_7$l*Sw2q1M zjPrV$u7eZUAYrbbl4S;yz@9xb)J6xsjhPGIt!nwYzGI0e@y&;BPZ}R#9X%L~=-~KO zW1g`)4qaU+TRIkPrGjFvw4PD!u+E04?KmB7rq15iBkqo&@AN>UvG8opTiyEKst&uN zs$lg#_U+xrn_v4He(|^dIJDhmK_!AIEUYPXI)HwUzxw2jyz$FlNoEX%ccjtJ%*A7n z#G>o8LdVMs9ekU$3KyP^;tLgV!`c+Es=HQWA|w$BMy3{7sA;@cVu{ug?3RrR!%Z94 z9vz+2ltL$gKb$DVc<)|BDZJ4kl`6tn!CA>a!`SQD5xXh{`YFX{?gQ^IyLJ-|MxOcG z<8^#Wp?1!P=n?0T%0~9WQz#+MA_~%8M$$`3vmQy>3lfS>FlNJg5Y?zb6)yU7pW8gi zM>$-5KBy^%L&JG#){e9TGt|X5O;fD3e~*gq)(*IS_wTB032wLn=1~kE27N-| zXM{oODb5IfkHW77P&IKOkh3QVQW;!k@kK&qJSw&5Qo_dAc$97I6rzJlNUsc6SlQr~ zu8LcG&+2BuzMV6j;kGn0rkZ)66pVVp($V$syZVL$duHj+B;n_}4Y4eoqNp6Jn?<-l zPr|-K1%vsFxk18g&oI-|Bs!9~>w%qsHo7s~L88^AM8AC}kp)1+r+rfclSm5+{3N{Oyc}Z1;Vw)uzlM%A+R2>Z2Ie}F>iAHx`yfzi8cY4E9 zFj?ifFS?%RT>30U`P;UL0P=FXm3%2ip{lz zG9NS*%ZKi6<@hEj#lFQ}oL+|9BJtC9f)njwcLJLN^8-$f3Q`ev&L;GdFc2`QZU)`r z!thR~8m|ylGostHRND)D8QMC6X#AK zXT5rCJ_O4r`Kj;yZvNdT?glrBCKp8@=9-F0Q8HsPNK$UQ|45kCq#E%KrxZ$Qd>n#l z6|i8VI@d+3)+UK2%MPY&B>{Rq;=o#yxQ@$QYET`XvY=7oJ6q7YUx>d76&p~ zCoKk;_1N6A7rS(l%t&x%^C4?V$25>8nR-hXta=$L%i0e437TXz&f&~z2Ywf-d_wX~ z-$4K1epF>KFMVm)fjmWsKrQY>nCq@TdjPVPBL|`DO;eJzM>^<*A+BnDOF{v5VRvXlf=r-ux-c8rDracYOX9bo zYva+aX6h<#n!|;c7(GQVs|)ep4bS(kiv_l|;pPq{U1omWc+GZMW^Z#hq%u(Z*u3HZ$xnGo)oSPo=GD zSD%=1un?{AM8Xta9ITHDTw&v&%!6c+8|`3J;3XqTb;na!nl3%Gk}-(Vb*6`&;6lH2 zFFP;4loverNM%#xa0Un_-x`DHi#D$zZY8j z$d0Fj1GKLBz7m-Up?Fqt;m}DE%yX%lUb`|n3r4xbpDwb7o`lD_Iyv3oqb@aiYxR(MbH;ulfe{8^Ogp^_AR z@Zh;IYxOu?mN8QWR+g7>ANUYXgz7hc6O!!QYk;jlDUvKh_cKf{BkA|Sj+;3}mAZov!i%k)#rW}a;S%G^`HN1biU`hy$kp-!s~N*Zz0@v zk-)M?Ckj(4)Ig!iKr8TRh>1{@$7c?M!=f?XBPRS^NeBYg%3<*snc%;RT9;rWMVVB-iJ`HwJnF)Tb6X0Cv20b9*sbp_*! z^_3FL2$gAO=ThcpGv@mVSuZ6qkdc6?*C#bi68D{=8>j8g%QU^Qh8{dIqP~;PpgpdW z^bmZATZjLe6FVN2R4WB+l;MZ-z}@eK*}bs;now-UC3RDF?Bh>4hB~@IU*Px*eZ2l@impKuJExCH;WBE#uq892qsl{ zXE14kaVcq$P#oCL+JmzknB!&2SfyLC{fwtBZsdxap7zUAvfaTGz%&5^wHrJXD;1si#* z{u@ui%wVd*8&rQGc!H?u*M{1R<_Wgz-k}vFy^J*Lk@owfS=tFRm1gjU1Q0H%{ZL}V z>f~`^gydX{$kt9dLV}%+w+lNYN%FL?{J#U(K~y_xesk>$9BwxtyyZH@&YQyj|H%Sy zOrbAP7%L=J7@Hu@2vVA09xF}-PL_}SsWgc9wNJ3xplpc}O)=I1g*UaIBBqz%blg_e zRjITLPY)TMjDJ6G0Q-A!6rRCdpM!lDv2f+{*tqw0IQ5AT;cMn#a2N&$p}z-`StOlf zSS>OfIZkLuGDDVX&fh&?=X@_JC}jE~G0`WW4Nk3HM?)vJhAs@j&L6<7e+;K@gV`PM=qF&;*FtYsBWjRldMU9Z2-|iDemf43 zDh!s`JO+y?-~N@a;qt#e0=<5qLAg~heJIOlQCY#L0A+%`s|eTco9Fi`3p(vqz3E7H zK(WKod9a$xv%TC(Kt8Z)*+u!L$p|p^Ag(!nIK6JiG zLXu@{?%Bs^-vO2$dz3>;;Z^i=_6|uDO1U_iNa8TnZ%^M+3YJ%<#RAS{|~ie9x1A}*3Hgf-}NV$uYElf)f9bBP3thu662X`uA%=^KS}lOKOuedo6raL zA)|44?Ic0&y1VTR*G_^fr#r{Qpm*%TFD-?q%?ud)*?Z9EpO3%!R_q08`(R2w#+qqCjr7Tt#4r!a&Kd-#s0 z`MpV!{5^#o9uI~HVPcVv7DttJ2Yf@6Cv%zO_cs(=5ZqKaBR3JwlJj+~3q*))MO z4>GvyDt5l%yV$sY9oA34`bjwTFl-!ywa>u%FsKC3Fn=jr{EZ+MUwM>)owF(4hiz|| zpa<+pRJ1Il4ga$uJi0UvJGeMqPm~VZ;V7|Mn8`5Ow|>Fa;k-~4g3BGQ^0+bx97Kf8 z%_1a(;9$oL9Q_nLdJ|W@>6^Lxz>DB_-vMh6z`ot+Xi6iIO;Iy=yjGYjV6td}prCZ1 zdvN?Syz=@Na)dcfJ#sh9%s@5=#W>78DT6|8mj_>h7lxw|ORF2qCW98f=nO!otWY`y zGRVmzJ4Cr@N>OJ%sEEfcQD>3pyDBLfxp>6dJxJ`1y)tMQq}tl3AgD2DW=#gWudFKXN~E(@n_A61{)^ zljs{>(ax*h`P&pf{*%mq%bNp*(gtF*+U9qjr3N#K>_>ix>>qy%?Ar&!F{&0J7>z%A zv{R}S{=Oq5U%Ef01pp?+?A;4Xrvvcg1+~0_dj9hj;e>5@VAQ*mfbfcse0Oz@_O{M_tJic@JKSwykC=9srH#gzmTva>8teSs z!4BIg;c5x}KV1q1`B#AN-wfN~)0J@d*uxy?TrmV@oM)5;t4dXPs??KZKAIG`DEkTk zrZ$fC)%k$e1%sH`%!H?QFKlVI`<&N@ejF_sAHOQ0|*$zMljsg{S2K*Nvl(6MCgl;;^fe*eWI}Nki-zXAgB1hSSbEqWHqLi)a zBEJ2+PXw!K@S_QnTW^mXs2=uXpTK_Vji{M-GCJFOX>B{*S`RN`>l|q z=p8%I2log1U+puI=Z~-Bi>#Kk*CXj=C~ILd0+Y4hvz{j6_$>rHIh*6K%~4h1JgAv3 zou8@3n_EXa(z6Ti|N5A3%?qi884Vn9L%0&YcLUzI2kt%tJ1>TEO(-ixe^XN#8+(64 zRT&a2k~%~jMmel^h<8E$rC_k9Nwt+aKYEJ48VRF=1WMo8(VI68y`7N50?)P-%V(lM-4OAPIdb9HI+;>Xsqx*msOU?_=k#In0t`dvYfG4M;H$6; ziCCTMMnbsGhftmGsm@dHXgb=Y%?^25wO6B)T9Rx!g+!s#VCRYG)-Wdq$fJDJna5(U z;^&Uq9$&w?B;?Kc)R6jwUY6mVWApI&Ji2@ubMz<&A{#{8I6QPj04IuDYz-4Vu}^?s zTW4~|oo5%W%EI~z?t>o+lJmG2Rlo60A%;HW|W3;2-%nGco!onhICZxVq zzxr!r|K|@OMS1oc7)|gW{a8!&)+zGwkD`(^3{nAlDcRS59q!Nn5>B5+Kl>{5WmmM{ znN}!g&+1*LJKs2P>koqO!*GHcjbSls`dsg)MH5;)h-u4-s4WGiyLob99rkfIuvJ2+ z2oPn&j}xu7644Oq^skc3@q4&meYsbi5bS9xUAh{Q+)qFEX}mf#`l=NeU~E z!jVtIu2+NVHv_8Oi6URkF#|(yvEQV{(;*8eX}9*HaY<3QFt}Jb3P(P{_kG{D^Q*u6 z`%t_V_Psm|NYJq;ss_;^25RD^v6?Kb$8P4~bN2eM{1G^DFJJa!KgA!92B0UAM=4;3 z;S`UNN+E?KD7~Z<7$k9UB#iPRbcw|>Y&^_!o_iUO_I6XQpNzD?1sE)X90M7J&Uajd z;%`IeyEz)Mwy_xoa;{T=w`&8n-+qGKB$l`ikf|u^!fc{}lBydg6Po-`q2n&Z#6lP- zXkM8Btk;o4(Q6|#JO)uh;uC!45ub&OFYg&R$B8SiW-y+Rtgf=RPQ!IAjjfgu`b38U z>^da>j`$|LcTIKt1l1!CKaM9)5Ry#&I7?9RQdeb#TRPnizmh)X+ux4be*k^>0@M|k zp)bFJ^5=h^^wnR6y5>2dpw<+_MC1SJuW?82Yv*T)#@}``a`P>y=Umlvw&sQ#nE9>$ zio5Y9%-6mVHR!d2Kx5#@{mnpg+fLsF!BLVLm`q?YLFWZ(r)ks3r@CC#%p?Sl_JI+y zeKK`OGSP96rcCtaV0!S_t;brQU3mZZU^|>Olv6bh918HSAiVu*;k|dl4XJS7ULl`x z*fR>{5_*}#i(^I{Wyr8VaEJ>k_L{f1`Xe-wH%*!lf% z#!Z3#&BP`hz(;-ydb^-^8B7dl$IOly=4O*12hJTuHKD(!SC6nF!=b~K1y(n!kRodZ zPT$4;D=y_%e)=c))!+M5P_xu#2L_M!rsL<^Uf|NP$y?iqS!^P}ad_~f-0;<}<(7Td z!lSG4qBPK(35*2g!-_8o(UEcC1DvfA-C|~wDqIE|55j1TZ+P8T^Jhm#oyAGP?hD|N zJHc|9l+iwvnoAx6k(Fj2PQ1Y@E&!-LVS zfF-aULPxA)2jpx1Z{!R&n7s=wbcrPROh_YC6Eeu=x$gxpB)##IR2v%{&@qA8f{+Gn zP>fCT@i-~(rx*eX&}l+`?-Bgz({YsaG|h5!cdQhv%h6-V(POyJ-h`wY(hM`nDZb&G z$^O%SB>n0yM+%2B27CVllt2CxsPP2pR;IL1@uUBYnGgOoQ~{Uul|{e&<>*(uqIJEi zvGy#X{Mg6QIziMq)>mi4FtDgl`2<~zdB9bz_j1fxBhvR&G`gzi=3eyKER61FZ+00f zGT=?~;`J%pux1FuTGQeAc01pv!*5y2ibafR4+NFM zLpQ?aY3QE^=3vA(VgJrvAZo{CWW1+bEt;*a5|5VfCn8kVvAQu1-nsc0PT#>l`=+ns zzubNTPM-|k2@xx9l9WX-o|vFfo3Z;w=U%Q)!e{CwaPmX+=MtXx_20__4{x+sx9HI2 z#}1*_Gtp?ele(cJ0Y$?kx54e)Rj~R1IM3I-@$2~5J*Qevh^m5vSA=6}M-gtQLYLHq z51L^}=k7Z9x*N7zeqR5-?fl$ze?o7hy^i4(&!zZ|?`HgpFJpYwmB`U!q41#E zyVA(5w_@M<&T#FR_-i8+pd%Gf>kz@nc!K}Hhl0^jWPEn-S_0;;1k`v8;~YIMuz3|; zJIaAoG{A4*1>Z?BR*#<|6q98mHIeqb?j~6n6^Cww?|F-{&i_7`?{-DF8&z}z!uQ^! zxcWNavG#cr>&XnHSx9~iol3~XQAUEq$V^}09cDmHBZ_3wP%wI`HJqsjam)9>`bp9^ z|5K_nqpoX3$3n4$`#%FNhaIm96dxxrpR#{vKX4Ok0;-D9K4V8RV;XsKy($hzB@D}e zm5z>+3?{tsH8=3G_uUJ#1JLu4s-}XEt!hs?Lz`ydV>-r}VFkH^M1#E_&fLql|KN}E zpYOAAJJ^{7>lFGG>>FVA_tAyNmmaL(tqT{acp#xDBU7Z@fMa*^9pCjfj+L;!I;N4M zRTd6i6;Jp{Bs06(3_0vXlawqmLBQy@fgJRf$J{wO->u(CEXGPfn#8!6+CWL`hRM;m zk=8bzaTuy}F!RxlU==BXi}Kt8iHh}vd!rpg3XpPPu z#|&pYp?t@`LKlwYd%q)GPGvDI&QJ=KCRG3aKO!4zkE7C%AR`xne1a+q)OgHF-2Q7F zZ97#rCbP48OHEzT9o~@M`U;_Nn=K5GDTSMAbZ)89l%2x+zjyKNcEJ+M$~Rsoe3)QM zd1utp&H~PQY!a3-)$3pgK3I4e+iql?)Gjt1hC>Hl*pbBV%(n%$8pF|>;Nq*Oc3uw0 zChd2rJ6~F{2B+?U{m+MsE`)oRpmIF>Sv#9L6ap`OoU#hqI#g|g7T-_L7#=vb8Q=!p zgJbXK1<$#L`_kQ3NKKv- z*Y`T&9UbTOwIFBuq&X{DTBL;;qAlpu5mxOk$J4sk)rGflv-{t+_;zn^0lxo9KG$vo zs;C$f9JisR6MFUDq{#UBSf94n_84vsd_cDN3s6?pbrL+7|N z1%ds$`V^%L7ru154IMd!w+e^T>(AX$huTQLi+ z9_Z-Kc1wqZjKVq?7BJg~*JIJ>fF*XC`XjGPnJaatucX} zk?N4(s&rthRTCJV;_??fn=77m5lc&D@N%t9*X!H*NkSDr+uK;F&|Mr^%^Pt@Jxl?eqn4CKP4849`wn_yB4 zRf_b&iw;yUw%3u;G)^=RJnscObn(SpeBZt7T{;6|=p)QHMNfp}frpT#Q!#<#nJu-? zw67Xl3a~0G{3kz2@rh56f9H3QT=8sJSU~auf5)AnTh;1u3$OH2R5V>zqdauKlL>b% zE|M4%tUz@VxW}sI;s!hM5N>)l*-Xwl4jyj=qtzC9v6>>M)q+l=4QU#DCjR#wxINk1 z#0m>e$Xx)GQW0$8DT)w9tkRGSsk9(H!EM>Ji3CMfP4BV-yV7u_+RorDAHltU23Nii zNiGQnKU)VrkwQc{0h2;+F*5rVmtJ=uOvF{oLXf6NhA>7Qy$S^(GlqK}U57^dxYL>jfhi_o9_X>uya_P4v7@U~Odr6AQCB-1})>^W|U0;91u({?si& zOwwUkVHEV|L!08p2>UQ56rmt}j_Nk0(aMohP`VsU%5YL)%ZjUZWbByFSX+x-tWM#? z{gF1&>9W&!@i>dtio_@$T`icB_dVH(g?gO-_cXY5eX%G63Cfj*VIWEUT-~Nc*Yq%7!CKBcSZRjP#@ zOpWzMv4HZRdT`%+LR|5+Z-Bx~J3MVY-q~EhGF=JM2k%+f(ZdW9*r<@w$D$i;TvUke zsZ$pK)`tbrk9v_wHT1@Jn9Ck>9u-mX3ysO3TtPAr!CasGAT#4XJ{aV;is| zgU#DPS$_1J-^zcud*y68m#d+d^MVgUF($W;g?@sICgZit7*}DWLi++-0lj&S-}gyQ zo>}2phxT*j?ioI{7HMrs5AObW3|h;=VwH8W>O`2EnPJb4MIKo_!IU*6=YSjB<5U(s z{d^TkyxJJjSO8qK0s@G1>n z!Gw-lsbE2AjKP!%Sy@sg3FBVn;_4bZhMQcpy29Skh!jY) zp^qk0Ft1=XV%B~egyxG*R@$x*KjlS0u~y{FW<7M7BYDnkJ9aXjonxoz#lmMWT1Bd0 z3uO6v$MOVkOr+)UX{FGe#19o2sWqGdkR%#w&-QotU!UjuWNz<5cnk3MtA*1?6zm6% zo&CH*Bsgqzk93@>IUNdxj?jYjQ`9bWOuG#hWUzEUtlke-Tnjs{fK#J$ET8fJN2^c{ z!|z)vAh*$aP@#5Yu<4N8f~n(!R6YL-rYIU#YdRRf_ynw-;(Pze5AcCwB{GaGfm#d_ ztv1CAsq(Q43m6pwiE^A-$zd*q84brCVCVjQeC3zEgg5-V&rWxt&6ZtY$KfD^7@lBr zatR0KvxdUuM8(CWBoT}8Ww47(`%roQ^g|!xN8k4KyyC*0eCodC(Cwai1RnfMz!`Kj z0G>%YDH#RrnhH2Gq+UcNB z3%OH``H_yI58pYECSs(iuFcjX%%NM{*<=Z(%&@~vHaEv4DrLv~B2~XnIoUy3=8VdU zrLx31$0uxss!DcjY;xh!NzOa7%)#{yk_h2sz;1*&1@i*QQ|QE=$zuI^ch(~4Bw=Yd z;_%)DOi?ndDn5H~KWVQ|+D}Q+43i}!CMeI83OK%Q@LqYs_ttnjG_XVWNm1dZ>#mn= z9nJpX6yL44;XR7%Q0Q{|kryUDNXM$apsmM6?v@~A*#taH)?ju&l)i~qr!l9ghi~yB zTsZ~~hSgQC~YUAo%n?6RNhBAhwRA8W? zDq#H)zWM9Fkvm3;YGkJw8%=lMp|=xuTmYx<#hw2ms+~QOG#sl$Dv%N-6-p{xk%LS^ z$G_tMzxIcJ$dA75>$&0lMgAWm)8Xd#h2uV03@F-=z;G|7hAKPh6*Kfmk zP_#5Qz1?S+?vj-cI(=hIFtHa+qI_^;>s*pLngM&dz7efykyaXSeJe1i)r7G_^0Zj7 zQMz^NF-h2V+vq855+?bWahWq2R}5xmFMDCz*U74qURAN9EZJi#W>L(lkn?AN6h*paT{B+*2F264 zc_7i6rOKkTW+6*S$0I(oV+RlJKg9lCk2L9#rWuJ&BJQiE{5X`Hdj$}6R()*}NtDu4 z^8qo_R+HOVN&dsde9ybwlaKVaoo_1Boo}Lrz2~0^*pqIGjH;yLR^OqUDGB)P>D%Fw z=fL7&IJG&wg{jZpG=r13!e{Rj+?7A6ia$?EW*bFax^|wQ)a20;@nAiAtLMH_;mA43$=l zkCox9&QHN;oo{`^SMkvYRw1|3Tf**^-2<1t0zUjsSXtuK?u=*c>SJw%@@Ias0#aTwTe;OY66wJ;C^`C6HI=ln%>|9*r?q!P|xoOUC z_gHUzd^^E~bc!?6Ls6wPMkTcgFiPnrrU+3;(y~Xyg~izGkc+2c$E%YtY*Z2^ok|36 zKsi;<_iMqDm?~NEr$%tJWn;cqSV^?0XU@cl}GNtG% zn2QKd5{s8SQ-xTmr<_}72!>6MlqC!OK6||IK+@w~*F2XUy@Y-469pTO!p3oU<9EVp-cH}Q7U0P`-1FYxQE>4$!!QfR zvZ;aD9xuP<0(3t?_e^*z0%jqFYJfXkpqz(ZLYlylV{0(9f#pz~0e_r#{NOM1;$OcJ zdTD?}%39pvn#ADJ##9FhLo5~b@2eYS;PxsF!{ZDN9pG8#AL92uJAuLn;~?3llh*=! zuL{O04}6kg`enHJ9i%wCSda?D;)*eDv;hU=+Zc2M9W4O zbNo~wDm4XIbfb6^bYM8v9>+eK^hk)N54oQjjWDT7Mw2m>_n0gpL!rIJmX1Uxq*aD< zJ#1>JtfjJ5+xfb1?8Q2~^Z01U?woD@?wrHU&S2ecHl1g^v^ZBGwnD1lAH1-!!9}M| zaBz8X90&j5BIzysD(0@o}qa#U-6>4n#`uwy4_ zHlWuZkoJ0{Sr&?56w1^A1?aYSdZNLek8n*bs?eO>7Hbs*9IE5zyEVz;A8zr@7loOP z;Cf*P;7Y-yq3G5rd-Z*GafnF@T-j^b4K-R{Pf)Cc2^9kC55cZ|u;(BgcV~Z-^@Y9m zFf1Q|E8Ys@Sr`b{KW9I?7X~cM_NcU==9Ad)>1H=8J&oEMVo@@sc<|Jar3Y3+(H0G- z-osaX*{gZSExx6*ITzpnn+KzvYzLH<@ubA;>;-;mEWAo9mewcX-zWp)WzIjahsD_e zcb{4!X3}quVLJy47r@~cz&*FX!>SAyu z*IaRkiw-W}MM8+iV$#8zRBXsW&lvoyCNYMk6C-ZD?POfKGg$jeFe45;=Sz9_Bh@tZ z%FG5+9Jd+iX2Qlsi8-WD3uz2(4(I*KMiDVm3;7zCUwAP|n(^pL(P1}i1H5ShlyLc1 z!aW~>{4vJ&7MwN}=kJ=K8c)!+;>^l2*hw@xDkAULaQyUX0IpDLyx(;ex$WAe;Rerr z;dPu;J9*@RJ3^Q2I0qiR1p}5Xk9Ui1jSt6SZAC~_LZS@LgrC(O$>FVpA)4`_VEUMN zrIYZnYDht%9jMVdiX(mFClD4$!CVl0&R9`AywkM|z&7H>VyTO1zmVnKEescPuL zQqIhB>fk{hv!0v9(;E(%8*ef@95KH!VtzDaVROXp@rb>X35%sA(?Ok=XvGYQo(?$j zwA%j36(tRnB0MUvj&T0&-Q0fRVcvhUMc5=_s;VOtvo zt~=fOIeaCX2YqC=1iF2YOHW0}OQbLL@I13HI(^orT=YdVq{yLL03q?0roK13kR-( z)AvGe2akR32>Q}}Tzqf`51%~4#_CB(Z0vliV2rL5s~hXA4@X?R)10L=nz}g)<1xSa z?|zOK{^E`8BBQq8yXqVeggOV?foa~Aka2W9kLeqclotneqIEN<5#@u3#0NOS#ZXR_ zga%#53)Y7i;~+1#k(n6Zx5bm^CDvQ469gC1i&e~>GsIx+C|yl7Nd-0qtVVf-N_^V0{Xcf;x zC@Syx_;a7ji3<-ioL{6jKg+J!IcEE_%*@R*7|cdVae_%xQ1!4&HQag6lfWCca6FVY z0glpJEQ?}Tfr>*}qcyS()8QW`>@eIXP*cBV&(T>KK|8@Y*mL0qNq?2e+69zr0iRT9 z$4)Yu>Zl$p-40nFF1Q?49*6Dk9c(-X>0ZFFXR*hweoC1sn2-AYRz<#LCoOn^`%i9S z&rCwcw^v~GW|(a9_22$;{L)Ql&Mx{oft?q?>Y32K2~^JE)1;ZSD+Bq&!6>RlR0Ya$ z=zT=*h5@w-qgflhej*1LG*h!0npj^4a3+I&Xbb8T&d<+XKY z`u)eH6tTI+*S_^@__{w{fz2WaKDL|sxF^{{oufcopYX`ikg_b9o12^Zm5C>?UQFb@ zdEbU*D1-MQvPdO@Sk-W{y`}>tIXpr!DJd(9CwM+|3BmOMNBfXiqLfBUf=HO`8xNu# zI?~@TI;5JIfFqN*=u(%2zEXwP(Bgxmm#^Ux7qU>jLr|g9#fE5gPxw4OSf5B;q+Ng~ z#96G5__5fgFk9#FE@0E6z5BT5qKmmh1nVrb8yoCdUE#pW3g<7KVLqRrMCe=3f_N4M z=9MtpSs_bcUEqv@Rh@8V#{&QJB`?M=EHKmSGe4MRFqmO5H^ZPm%U~v&6b<_1|37>G z8D?8{U5BA#uC?~wCshu&ZVsIrjc$OAzyJ{-34$OAb>{ae7pOms$21d9oCxj{a9;T>OS9BU)@`E>ZHB*SaXdz<`}7WSW~)T0V&?2>t@0_Y9jjeq+15g^?z?V)NqfRyKY-BX?>DQe?Ut(3p80ql(m zFOyT+mu+rZL?F zVjn%lfjjn9bJ@tVuo5a{wgI>u*g9CYC}w)NdUOpJE#tEgBzt{yMtv;q>|$|i8|`$2 z-e81Qk%z`4ki`+wRtKB&GkE62acr#|K{qmJM+uU43$3J$q|-sGm7tw;k;F0LR*YCj z(47dQg6nO*k}7ESAZ2g8aolg)s&t@xw5o=mYD89b=xAi1g{K;-zgjBaU@ZyYV^;_( z&B|)zIp5|nU=f6!I7T}T_k7bFKK?I(y=Q@$6Oi2uNVPws=i<<{cYbKVw0$*NBHy%T zn>gS2io9xIZuwNJN6(6o?Js%TH|8KxTa^!^yQZi~tn7r9n2P8`hM3DKn6) z`iZwImqLU~;X%aM0K9HoUgQb@35W>8{s`zSd3wP#o}(B7#R&T3H{(nFQ+_3wY^`69 z+5rbQ+3MI~{{YW)?!YI0=hyH(-}60KTU&!;uGANCnACRGe+O%j5x{bACsn@EWd{of zms?m7xROI9sS}RMEvp-kv}x8-BtfcHT7c_dix?)Zh#Uys|KEkrRgvJ5 z^Fw+*=+U2YT+85Kpy74r!sKnPCB)Zf6(P{N+lDbEju{n|2_y73wh&_q-5P&Q;kej5ac zl!9#lZxe+7(*@t!Tz$NE`Hm&VAMOy02vooEc5;s>8UkoW;RHH%*m(N*Y+Joyw>t~$ zJO#8DfP#Q+3(U0uGvgnl*bNsYdp9R(NYJR!CNEb~5f7f?knR;oGmDl^0wYj&$)kvH zc_+nhKX?U$%X?gZb~B)+v|9~&V5q=5`$QKpI}3TCoc~m zSNdMLQOfC9BQ^$w1+ZNJ3-Q5#*NEQ+lXq#m@Nki?44R90~cDolC%sPmGt7Ig?+QVwoRE-VTlcHUaFw7S$f zXrX27zCLxU9*wS=L=`Xx_9AfERX!O<5MdCRu;KH^%XrlFw1h+u5x6*1%EDlHS(}JJ zoP#aIQyDHu-U5Ft;`wB@4h&yS^9+wkz$mzcX``VdgGd`6lo`cHS}`KiLL5bik{BlT zUJl9_4~9k|p$KkNM;uAD=p4p8OoQd{YJ!jNaW#4G9;&|}nn4fU>0J@{fopXM`A~<`!SP?_#K43xClTdjM}^GTHY~ zA!iA|PJqRGfcW17=@t-0rDfWIuW_c^!Q$L3Hg|S_wQm46??)AbG1~UKiZ}l^0JrO^ zm!~WM(<WclaPgCmW$Hu;z@&obL&@=0mWHxlVplF`(>`{}w5;mdR#?4hNsu6@-nHb~_~0CC@WF>X(^snEU-t1AGA>*Mv@?U=?8~+R&bY z5@i8}JXMgvTz9zjj?r^Z2RWGm>@MH}F~{%~8BAq#jwnLQXc%TBTA@9w;6wq-2G)>o z)Nffl#>#^U3qTH|!a-%u(+wSiPTOF;pX1lRe9?zYLIde8@XT)mm%ik~Ht+p$;KUpK zMOjh_H~?VE?{2S36vklW{bLRA8=rdy*xLygrwcZkgK@B&1YSO{K=3DNd%;intv?O$ zB2XwGIsg<|ki-$XNrFc%_JO54fOrs1)#d#2J#l?WP* zEj(%HC<@iEpG4G=0;woa1Hn5LK3S&KG8hcq0bDPxsd^~qlteTXntr}DLFDOPKg6LT z1rpi^ysu8R!>6t)th5L&RS=`*@>I8D9qW9jtqDEJspH7CS(#S8{?uDwhpT$j597sw zs})mUs6`&s4`Hj`?x1Z9x|G9^s=|J5X&?Q=bT9~_Zm^}P0Le0N^4owX zf7LHMx4$2ly)`_}1hp^;#Bu-t(*cz_O@MD{^~pAPXgI(reA&GL&pi1o9>4e>@T!0*h^T}Q5f@&c`05uSrYkh~$_lgD>neFx3ffsRx?xUkU`Rn}Q^f^&p1eF) z{3xeU2AV7^yX%6BeMuD|Y0MYBjWmnN3Aa-IO9o2u81TM&!FM)@a`(JJR;)xD-ZmXV z1h$cZw2Fe-q_W=C-OJz9?)4&Pi{Ohe^Z=~2kBArck?dpuP5~~U1n4M-gaNHw1-uRl ziUEt>;tZvoE#4xpW{d__-bK>DxcxkbPvNHZT0iF1G4r#QFQfYrAG z`CuG$<&Z|(#F+5Dsu(!f%eM(wrILcZzIn4)!N>ZuII?pQU*32Ozy9ejqpKb6zWpZL zb=MuZ{f^gRad8RVUJu=F7q%Snb!r)V~eaHSVs`+*P39% zgVJ2uGZQ7T9B7Lw`o`;4o4)>Bh5;P7OF?6D%Qt^;>%*_GIN3iqA!(RTx!M%*MzW}WE-TbT% zSO*=oF$;^>jX-PyCixI<30}NL6BbV0j7wkqB5>|3Fv@{J9~U0j@=AsI4$zCe5Ae>D zz~jGzzw%dq5`X$fK7haW`OCPv>mzj(3y2sh_th>b0y+c{KY~*Pde0{yDXeeA*wif# zzHRO0UpsloVv|sExf*0ucGn zOle%skKpoX0l)}7J;ERU;Ry3z{5`BBzk$0?&f&z`5|νMD1JIClIv&YU>|r4;hK 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 literal 0 HcmV?d00001 diff --git a/share/icons/spinner.gif b/share/icons/spinner.gif new file mode 100644 index 0000000000000000000000000000000000000000..45dc1f6d4b9a3d636bfb208db47eb9bff18dfe2d GIT binary patch literal 1570 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 TK$4Z@00000NkvXXu0mjfxjm+? literal 0 HcmV?d00001 diff --git a/t/00-load.t b/t/00-load.t new file mode 100644 index 0000000..3cd6d93 --- /dev/null +++ b/t/00-load.t @@ -0,0 +1,10 @@ +#!perl -T + +use 5.014; +use Test::More tests => 1; + +BEGIN { + use_ok( 'WWW::PipeViewer' ) || print "Bail out!\n"; +} + +diag( "Testing WWW::PipeViewer $WWW::PipeViewer::VERSION, Perl $], $^X" ); diff --git a/t/kwalitee.t b/t/kwalitee.t new file mode 100644 index 0000000..7172422 --- /dev/null +++ b/t/kwalitee.t @@ -0,0 +1,20 @@ +#!perl + +use 5.006; +use strict; +use warnings FATAL => 'all'; +use Test::More; + +BEGIN { + plan( skip_all => 'these tests are for release candidate testing' ) + unless $ENV{RELEASE_TESTING}; +} + +eval { + require Test::Kwalitee; + Test::Kwalitee->import('kwalitee_ok'); + kwalitee_ok(); + done_testing(); + }; + +plan( skip_all => 'Test::Kwalitee not installed; skipping' ) if $@; diff --git a/t/pod.t b/t/pod.t new file mode 100644 index 0000000..ee8b18a --- /dev/null +++ b/t/pod.t @@ -0,0 +1,12 @@ +#!perl -T + +use strict; +use warnings; +use Test::More; + +# Ensure a recent version of Test::Pod +my $min_tp = 1.22; +eval "use Test::Pod $min_tp"; +plan skip_all => "Test::Pod $min_tp required for testing POD" if $@; + +all_pod_files_ok();