aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rwxr-xr-xBuild.PL93
-rw-r--r--Changes7
-rw-r--r--LICENSE201
-rw-r--r--MANIFEST39
-rw-r--r--MANIFEST.SKIP79
-rw-r--r--Makefile.PL34
-rw-r--r--README.md87
-rwxr-xr-xbin/gtk-straw-viewer3676
-rwxr-xr-xbin/straw-viewer4230
-rw-r--r--lib/WWW/StrawViewer.pm1045
-rw-r--r--lib/WWW/StrawViewer/Activities.pm93
-rw-r--r--lib/WWW/StrawViewer/Authentication.pm216
-rw-r--r--lib/WWW/StrawViewer/Channels.pm190
-rw-r--r--lib/WWW/StrawViewer/CommentThreads.pm103
-rw-r--r--lib/WWW/StrawViewer/GetCaption.pm280
-rw-r--r--lib/WWW/StrawViewer/GuideCategories.pm85
-rw-r--r--lib/WWW/StrawViewer/Itags.pm319
-rw-r--r--lib/WWW/StrawViewer/ParseJSON.pm76
-rw-r--r--lib/WWW/StrawViewer/ParseXML.pm311
-rw-r--r--lib/WWW/StrawViewer/PlaylistItems.pm167
-rw-r--r--lib/WWW/StrawViewer/Playlists.pm124
-rw-r--r--lib/WWW/StrawViewer/RegularExpressions.pm89
-rw-r--r--lib/WWW/StrawViewer/Search.pm175
-rw-r--r--lib/WWW/StrawViewer/Subscriptions.pm272
-rw-r--r--lib/WWW/StrawViewer/Utils.pm735
-rw-r--r--lib/WWW/StrawViewer/VideoCategories.pm97
-rw-r--r--lib/WWW/StrawViewer/Videos.pm229
-rw-r--r--share/gtk-straw-viewer.desktop10
-rw-r--r--share/gtk3-straw-viewer.glade3606
-rw-r--r--share/icons/default_thumb.jpgbin0 -> 2310 bytes
-rw-r--r--share/icons/feed.pngbin0 -> 901 bytes
-rw-r--r--share/icons/feed_gray.pngbin0 -> 2161 bytes
-rw-r--r--share/icons/logo.pngbin0 -> 121443 bytes
-rw-r--r--share/icons/spinner.gifbin0 -> 1570 bytes
-rw-r--r--share/icons/user.pngbin0 -> 4065 bytes
-rw-r--r--t/00-load.t10
-rw-r--r--t/kwalitee.t20
-rw-r--r--t/pod.t12
39 files changed, 16711 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index ecf66f8..c435823 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,4 @@ inc/
/MANIFEST.bak
/pm_to_blib
/*.zip
+/bin/*.json
diff --git a/Build.PL b/Build.PL
new file mode 100755
index 0000000..91e314e
--- /dev/null
+++ b/Build.PL
@@ -0,0 +1,93 @@
+#!/usr/bin/perl
+
+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::StrawViewer',
+ license => 'perl',
+ dist_author => q{Trizen <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d>},
+ dist_version_from => 'lib/WWW/StrawViewer.pm',
+ release_status => 'stable',
+
+ build_requires => {
+ 'Test::More' => 0,
+ },
+
+ 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,
+ '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,
+ 'List::Util' => 1.43,
+ )
+ : (),
+ },
+
+ recommends => {
+ 'LWP::UserAgent::Cached' => 0, # cache support
+ 'Term::ReadLine::Gnu::XS' => 0, # for better STDIN support
+ '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-StrawViewer-*'],
+ create_makefile_pl => 'traditional',
+);
+
+$builder->script_files(
+ ['bin/straw-viewer',
+ ($gtk ? ('bin/gtk-straw-viewer') : ()),
+ ]
+ );
+
+$builder->share_dir('share') if $gtk;
+$builder->create_build_script();
diff --git a/Changes b/Changes
new file mode 100644
index 0000000..73abf78
--- /dev/null
+++ b/Changes
@@ -0,0 +1,7 @@
+# Revision history for straw-viewer.
+# Only the most important changes and features are included here.
+
+# For all changes, check out the release notes at:
+# https://github.com/trizen/straw-viewer/releases
+
+[CHANGELOG]
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..28ef1bd
--- /dev/null
+++ b/MANIFEST
@@ -0,0 +1,39 @@
+bin/gtk-straw-viewer
+bin/straw-viewer
+Build.PL
+Changes
+lib/WWW/StrawViewer.pm
+lib/WWW/StrawViewer/Activities.pm
+lib/WWW/StrawViewer/Authentication.pm
+lib/WWW/StrawViewer/Channels.pm
+lib/WWW/StrawViewer/CommentThreads.pm
+lib/WWW/StrawViewer/GetCaption.pm
+lib/WWW/StrawViewer/GuideCategories.pm
+lib/WWW/StrawViewer/Itags.pm
+lib/WWW/StrawViewer/ParseJSON.pm
+lib/WWW/StrawViewer/ParseXML.pm
+lib/WWW/StrawViewer/PlaylistItems.pm
+lib/WWW/StrawViewer/Playlists.pm
+lib/WWW/StrawViewer/RegularExpressions.pm
+lib/WWW/StrawViewer/Search.pm
+lib/WWW/StrawViewer/Subscriptions.pm
+lib/WWW/StrawViewer/Utils.pm
+lib/WWW/StrawViewer/VideoCategories.pm
+lib/WWW/StrawViewer/Videos.pm
+LICENSE
+Makefile.PL
+MANIFEST This list of files
+META.json
+META.yml
+README.md
+share/gtk-straw-viewer.desktop
+share/gtk3-straw-viewer.glade
+share/icons/default_thumb.jpg
+share/icons/feed.png
+share/icons/feed_gray.png
+share/icons/logo.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..4c55d25
--- /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-YoutubeViewer-[\d\.\_]+
+WWW-YoutubeViewer-*
diff --git a/Makefile.PL b/Makefile.PL
new file mode 100644
index 0000000..bd6cee2
--- /dev/null
+++ b/Makefile.PL
@@ -0,0 +1,34 @@
+# Note: this file was auto-generated by Module::Build::Compat version 0.4231
+require 5.016;
+use ExtUtils::MakeMaker;
+WriteMakefile
+(
+ 'NAME' => 'WWW::StrawViewer',
+ 'VERSION_FROM' => 'lib/WWW/StrawViewer.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,
+ 'Term::ANSIColor' => 0,
+ 'Term::ReadLine' => 0,
+ 'Test::More' => 0,
+ 'Text::ParseWords' => 0,
+ 'Text::Wrap' => 0,
+ 'URI::Escape' => 0
+ },
+ 'INSTALLDIRS' => 'site',
+ 'EXE_FILES' => [
+ 'bin/straw-viewer'
+ ],
+ 'PL_FILES' => {}
+)
+;
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7be95dd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,87 @@
+## straw-viewer
+
+A lightweight application for searching and streaming videos from YouTube, using the API of [invidio.us](https://invidio.us/).
+
+### straw-viewer
+
+* command-line interface to YouTube.
+
+![straw-viewer](https://user-images.githubusercontent.com/614513/73046877-5cae1200-3e7c-11ea-8ab3-f8c444f88b30.png)
+
+### gtk-straw-viewer
+
+* GTK+ interface to YouTube.
+
+![gtk-straw-viewer](https://user-images.githubusercontent.com/614513/73087694-93ffdb80-3edb-11ea-8fea-05901d72f68d.png)
+
+### AVAILABILITY
+
+Under development.
+
+### INSTALLATION
+
+To install `straw-viewer`, run:
+
+```console
+ perl Build.PL
+ sudo ./Build installdeps
+ sudo ./Build install
+```
+
+To install `gtk-straw-viewer` along with `straw-viewer`, run:
+
+```console
+ perl Build.PL --gtk
+ sudo ./Build installdeps
+ sudo ./Build install
+```
+
+### DEPENDENCIES
+
+#### For straw-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-straw-viewer:
+
+* [Gtk3](https://metacpan.org/release/Gtk3)
+* [File::ShareDir](https://metacpan.org/release/File-ShareDir)
+* \+ the dependencies required by straw-viewer.
+
+#### 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
+```
+
+### SUPPORT AND DOCUMENTATION
+
+After installing, you can find documentation with the following commands:
+
+ man straw-viewer
+ perldoc WWW::StrawViewer
+
+### 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-straw-viewer b/bin/gtk-straw-viewer
new file mode 100755
index 0000000..870b654
--- /dev/null
+++ b/bin/gtk-straw-viewer
@@ -0,0 +1,3676 @@
+#!/usr/bin/perl
+
+# Copyright (C) 2010-2020 Trizen <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d>.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of either: the GNU General Public License as published
+# 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 Straw Viewer
+# Fork: 14 February 2020
+# Edit: 14 February 2020
+# https://github.com/trizen/youtube-viewer
+#-------------------------------------------------------
+
+# This is a fork of youtube-viewer:
+# https://github.com/trizen/youtube-viewer
+
+use utf8;
+use 5.014;
+
+use warnings;
+no warnings 'once';
+
+my $DEVEL; # true in devel mode
+use if ($DEVEL = 1), lib => qw(../lib); # devel only
+
+use WWW::StrawViewer v3.7.4;
+use WWW::StrawViewer::RegularExpressions;
+
+use Gtk3 qw(-init);
+use File::ShareDir qw(dist_dir);
+use File::Spec::Functions qw(
+ rel2abs
+ catdir
+ catfile
+ curdir
+ updir
+ path
+ tmpdir
+ file_name_is_absolute
+ );
+
+binmode(STDOUT, ':utf8');
+
+my $appname = 'GTK+ Straw Viewer';
+my $version = $WWW::StrawViewer::VERSION;
+
+# Share directory
+my $share_dir = $DEVEL ? '../share' : dist_dir('WWW-StrawViewer');
+
+sub VIDEO_PART () { 'contentDetails,statistics,snippet' }
+
+# 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, 'straw-viewer');
+my $config_file = catfile($config_dir, "gtk-straw-viewer.conf");
+my $youtube_users_file = catfile($config_dir, 'users.txt');
+my $history_file = catfile($config_dir, '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_safeSearch_combobox => 1,
+ 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 --title=*TITLE* --no-ytdl},
+ },
+ mplayer => {
+ cmd => q{mplayer},
+ srt => q{-sub *SUB*},
+ audio => q{-audiofile *AUDIO*},
+ fs => q{-fs},
+ arg => q{-prefer-ipv4 -really-quiet -title *TITLE*},
+ },
+ 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,
+
+ # Straw options
+ dash_support => 1,
+ dash_mp4_audio => 1,
+ dash_segmented => 1, # may load slow
+ prefer_mp4 => 0,
+ prefer_av1 => 0,
+ maxResults => 10,
+ resolution => 'best',
+ videoDimension => undef,
+ videoEmbeddable => undef,
+ videoLicense => undef,
+ videoSyndicated => undef,
+ publishedBefore => undef,
+ publishedAfter => undef,
+ hl => 'en_US',
+ regionCode => undef,
+
+ comments_width => 80, # wrap comments longer than `n` characters
+ comments_order => 'time', # valid values: time, relevance
+
+ # 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'],
+ captions_dir => tmpdir(),
+ get_captions => 1,
+ auto_captions => 0,
+ cache_dir => undef, # will be defined later
+
+ # Others
+ env_proxy => 1,
+ http_proxy => undef,
+ prefer_fork => (($^O eq 'linux') ? 0 : 1),
+ debug => 0,
+ fullscreen => 0,
+ audio_only => 0,
+
+ tooltips => 1,
+ tooltip_max_len => 512, # max length of description in tooltips
+
+ use_invidious_api => 0,
+
+ 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'},
+ youtube_viewer => undef,
+ youtube_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_max => 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, "gtk3-straw-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,
+ 'category_id_entry' => \my $category_id_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,
+ 'comboboxtext5' => \my $safesearch_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,
+ 'videos_checkbox' => \my $search_for_videos_checkbox,
+ 'playlists_checkbox' => \my $search_for_playlists_checkbox,
+ 'channels_checkbox' => \my $search_for_channels_checkbox,
+ 'spinbutton1' => \my $spin_results,
+ 'spinbutton2' => \my $spin_start_with_page,
+ 'spinbutton3' => \my $spin_published_within,
+ '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('', @_));
+
+ return if $warning =~ / at \(eval /;
+ return if $warning =~ /\bunhandled exception in callback:/;
+
+ $warning = "[" . localtime(time) . "]: " . $warning . "\n";
+ print STDERR $warning;
+
+ set_text($warnings_textview, $warning, append => 1);
+};
+
+# __DIE__ handle
+local $SIG{__DIE__} = sub {
+ my $error = join('', @_);
+ my $caller = [caller]->[0];
+
+ # Ignore eval() errors
+ return if $error =~ / at \(eval /;
+
+ # Just print the third-party errors,
+ # without displaying them to the user.
+ if (not $caller =~ /^(?:main\z|WWW::StrawViewer\b)/) {
+ print STDERR "@_\n";
+ 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=>> Previous warnings:\n" . get_text($warnings_textview)
+ );
+ warn $error;
+ $errors_window->show;
+ return 1;
+};
+
+#---------------------- LOAD IMAGES ----------------------#
+my $app_icon_pixbuf = 'Gtk3::Gdk::Pixbuf'->new_from_file(catfile($icons_path, "logo.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, 'youtube-viewer');
+}
+
+# Create the cache directory (if needed)
+if (not -d $CONFIG{cache_dir}) {
+ require File::Path;
+ File::Path::make_path($CONFIG{cache_dir})
+ or warn "[!] Can't create dir `$CONFIG{cache_dir}': $!";
+}
+
+{
+ my $split_string = sub {
+ grep { $_ ne '' } split(/\W+/, lc($_[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(lc($_), $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{lc($line)};
+ }
+
+ require List::Util;
+
+ # 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{lc($str)}) {
+ if (set_history()) {
+
+ if ($is_search_keyword) {
+ say {$history_fh} $str;
+ }
+ else {
+ say {$history_fh} "~" . $str;
+ }
+ }
+ undef $history{$str};
+ update_history_dict($str);
+ }
+ }
+}
+
+# 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';
+ }
+}
+elsif ($CONFIG{video_player_selected} =~ /mpv/i) { # update for mpv 0.32 (#290)
+#<<<
+ my $mpv = $CONFIG{video_players}{$CONFIG{video_player_selected}};
+ $mpv->{arg} =~ s/(--title)\s+(\*TITLE\*)/$1=$2/g;
+ $mpv->{audio} =~ s/(--audio-file)\s+(\*AUDIO\*)/$1=$2/g;
+ $mpv->{srt} =~ s/(--sub-file)\s+(\*SUB\*)/$1=$2/g;
+#>>>
+}
+
+{
+ 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 youtube-viewer
+$CONFIG{youtube_viewer} //= which_command('youtube-viewer') // 'youtube-viewer';
+
+my $yv_obj = WWW::StrawViewer->new(
+ escape_utf8 => 1,
+ config_dir => $config_dir,
+ hl => $CONFIG{hl},
+ lwp_env_proxy => $CONFIG{env_proxy},
+ cache_dir => $CONFIG{cache_dir},
+ use_invidious_api => $CONFIG{use_invidious_api},
+ authentication_file => $authentication_file,
+ );
+
+$yv_obj->load_authentication_tokens();
+
+if (defined $yv_obj->get_access_token()) {
+ show_user_panel();
+}
+else {
+ $statusbar->push(1, 'Not logged in.');
+}
+
+require WWW::StrawViewer::Utils;
+my $yv_utils = WWW::StrawViewer::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);
+
+# Set search for videos
+$search_for_videos_checkbox->set_active(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(
+ videoSyndicated comments_order
+ maxResults videoDimension
+ videoEmbeddable videoLicense
+ publishedAfter publishedBefore
+ regionCode videoCategoryId
+ debug http_proxy use_invidious_api
+ )
+ ) {
+
+ if (defined $CONFIG{$option_name}) {
+ my $code = \&{"WWW::StrawViewer::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});
+
+ # Prefer MP4 over WEBM
+ $yv_obj->set_prefer_mp4($CONFIG{prefer_mp4} ? 1 : 0);
+
+ # Prefer AV1 over WEBM
+ $yv_obj->set_prefer_av1($CONFIG{prefer_av1} ? 1 : 0);
+
+ # Set the "More options" expander
+ $more_options_expander->set_expanded($CONFIG{active_more_options_expander});
+
+ # Combo boxes setting config value
+ $safesearch_combobox->set_active($CONFIG{active_safeSearch_combobox});
+
+ 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);
+ }
+
+ # Straw 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 with CLI youtube-viewer
+ {
+ my $item = 'Gtk3::ImageMenuItem'->new("Play in terminal");
+ $item->signal_connect(activate => \&play_selected_video_with_cli_youtube_viewer);
+ $item->set_property(tooltip_text => "Play with youtube-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 youtube 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_youtube_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{^.*?(<big><b>.*?</b></big>)}s;
+
+ $feeds_title->set_markup("<big>$video_title</big>");
+ $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_safesearch_changed {
+ $CONFIG{active_safeSearch_combobox} = $safesearch_combobox->get_active;
+ $yv_obj->set_safeSearch($safesearch_combobox->get_active_text);
+}
+
+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/) {
+ $spin_published_within->hide;
+ }
+ else {
+ $spin_published_within->show;
+ }
+
+ spin_published_within_changed();
+}
+
+sub spin_published_within_changed {
+ my $period = $published_within_combobox->get_active_text;
+
+ if ($period =~ /^any/) {
+ $yv_obj->set_publishedAfter(undef);
+ }
+ else {
+ my $amount = $spin_published_within->get_value;
+ my $date = $yv_utils->period_to_date($amount, $period);
+ $yv_obj->set_publishedAfter($date);
+ }
+}
+
+# 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], ["<big><b>\t$text</b></big>"]);
+ return 1;
+}
+
+sub append_categories {
+ my ($categories, $type) = @_;
+
+ foreach my $category (@{$categories->{items}}) {
+
+ # Ignore nonassignable categories
+ $category->{snippet}{assignable} || next;
+
+ my $label = $yv_utils->get_title($category);
+ my $id = $category->{id};
+
+ $label =~ s{&}{&amp;}g;
+
+ my $iter = $cats_liststore->append;
+ $cats_liststore->set(
+ $iter,
+ 0 => $label,
+ 1 => $id,
+ 2 => $feed_icon_pixbuf,
+ 3 => $type,
+ );
+ }
+ return 1;
+}
+
+{
+ # Standard categories:
+ add_category_header("Categories");
+
+ my $cats = $yv_obj->video_categories();
+ if (ref($cats) eq 'HASH' and ref($cats->{items}) eq 'ARRAY') {
+
+ my $help_text = '';
+ foreach my $cat (sort { $a->{id} <=> $b->{id} } @{$cats->{items}}) {
+ $cat->{snippet}{assignable} || next;
+ $help_text .= sprintf("%2d - %s\n", $cat->{id}, $yv_utils->get_title($cat));
+ }
+
+ # Set tooltip text for "CategoryID" entry
+ chomp($help_text);
+ $category_id_entry->set_tooltip_text($help_text);
+
+ # Append the categories to the "Categories" tab
+ append_categories($cats, 'cat');
+ }
+
+ # EDU categories:
+ #add_category_header("EDU Categories");
+ #append_categories($yv_obj->get_educategories(), 'edu-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, "<big><b>\t$main_label</b></big>");
+ #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}, VIDEO_PART);
+
+ if ($yv_utils->has_entries($info)) {
+ if (not play_video($info->{results}{items}[0])) {
+ 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);
+ }
+
+ spin_published_within_changed();
+
+ # 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();
+ }
+
+ # Set the category ID
+ my $category_id = $category_id_entry->get_text;
+ if ($category_id =~ /^\d+\z/) {
+ $yv_obj->set_videoCategoryId($category_id);
+ }
+ else {
+ $yv_obj->set_videoCategoryId();
+ }
+
+ my @types;
+ if ($search_for_playlists_checkbox->get_active) {
+ push @types, 'playlist';
+ }
+
+ if ($search_for_channels_checkbox->get_active) {
+ push @types, 'channel';
+ }
+
+ if ($search_for_videos_checkbox->get_active) {
+ push @types, 'video';
+ }
+
+ my $type = @types ? join(',', @types) : 'video';
+ display_results($yv_obj->search_for($type, $keywords));
+
+ return 1;
+}
+
+sub encode_entities {
+ my ($text) = @_;
+
+ return q{} if not defined $text;
+
+ $text =~ s/&/&amp;/g;
+ $text =~ s/</&lt;/g;
+ $text =~ s/>/&gt;/g;
+
+ return $text;
+}
+
+sub decode_entities {
+ my ($text) = @_;
+
+ return q{} if not defined $text;
+
+ $text =~ s/&amp;/&/g;
+ $text =~ s/&lt;/</g;
+ $text =~ s/&gt;/>/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 = '<big><b>' . ('=' x 20) . '</b></big>';
+ $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_youtube_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, $token) = @_;
+
+ $token // return; # no next page is available
+
+ my $iter = $liststore->append;
+
+ $liststore->set(
+ $iter,
+ 0 => "<big><b>LOAD MORE</b></big>",
+ 3 => $url,
+ 5 => $token,
+ 7 => 'next_page',
+ );
+}
+
+sub determine_image_format {
+ #
+ ## Code from: https://metacpan.org/source/SREZIC/Image-Info-1.39/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 m,(^\/\* XPM \*\/)|(static\s+char\s+\*\w+\[\]\s*=\s*\{\s*"\d+),;
+ return "XBM" if m|^(?:\/\*.*\*\/\n)?#define\s|;
+ return "SVG" if /^(?:[\012\015\t ]*<svg\b|<\?xml)/;
+ return undef;
+}
+
+sub lwp_get {
+ my ($url) = @_;
+
+ state %cache;
+
+ my $data = $cache{$url} // $yv_obj->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) = @_;
+
+ if (not $yv_utils->has_entries($results)) {
+ die "No results...\n";
+ }
+
+ add_results_to_history($results) if not $from_history;
+
+ my $url = $results->{url};
+ #my $info = $results->{results} // {};
+ my $items = $results->{results} // [];
+
+ use Data::Dump qw(pp);
+ pp $items;
+
+ hide_feeds_window();
+
+ #~ 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);
+ }
+ }
+
+ append_next_page($url); #, #$info->{nextPageToken});
+}
+
+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], ["<b>" . encode_entities($title) . "</b>" . "\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 =
+ '<big><b>'
+ . encode_entities($title)
+ . "</b></big>\n\n"
+ . "<b>$symbols{face}\t</b> "
+ . encode_entities($channel_id) . "\n"
+ . "<b>$symbols{crazy_arrow}\t</b> "
+ . $yv_utils->get_publication_date($subscription)
+ . "\n\n<i>"
+ . encode_entities($row_description) . '</i>';
+
+ my $type_label = "<b>$symbols{diamond}</b> " . '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/^/&#x200e;/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( "<big><b>"
+ . encode_entities($title)
+ . "</b></big>\n"
+ . "<b>$symbols{up_arrow}\t</b> "
+ . $yv_utils->set_thousands($yv_utils->get_likes($video)) . "\n"
+ . "<b>$symbols{down_arrow}\t</b> "
+ . $yv_utils->set_thousands($yv_utils->get_dislikes($video)) . "\n"
+ . "<b>$symbols{ellipsis}\t</b> "
+ . encode_entities($yv_utils->get_category_name($video)) . "\n"
+ . "<b>$symbols{face}\t</b> "
+ . encode_entities($yv_utils->get_channel_title($video)) . "\n" . "<i>"
+ . encode_entities($row_description)
+ . "</i>");
+
+ my $info_label =
+ reflow_text( "<b>$symbols{play}\t</b> "
+ . $yv_utils->get_time($video) . "\n"
+ . "<b>$symbols{diamond}\t</b> "
+ . $yv_utils->get_definition($video) . "\n"
+ . "<b>$symbols{views}\t</b> "
+ . $yv_utils->set_thousands($yv_utils->get_views($video)) . "\n"
+ . "<b>$symbols{right_arrow}\t </b>"
+ . $yv_utils->get_publication_date($video));
+
+ $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_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( '<big><b>'
+ . encode_entities($title)
+ . "</b></big>\n\n"
+ . "<b>$symbols{face}\t</b> "
+ . encode_entities($yv_utils->get_channel_title($channel)) . "\n"
+ . "<b>$symbols{play}\t</b> "
+ . encode_entities($channel_id) . "\n"
+ . "<b>$symbols{crazy_arrow}\t</b> "
+ . $yv_utils->get_publication_date($channel)
+ . "\n\n<i>"
+ . encode_entities($row_description)
+ . '</i>');
+
+ my $type_label = reflow_text("<b>$symbols{diamond}</b> " . 'Channel' . "\n");
+
+ $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( '<big><b>'
+ . encode_entities($title)
+ . "</b></big>\n\n"
+ . "<b>$symbols{face}\t</b> "
+ . encode_entities($channel_title) . "\n"
+ . "<b>$symbols{play}\t</b> "
+ . encode_entities($playlist_id) . "\n"
+ . "<b>$symbols{crazy_arrow}\t</b> "
+ . $yv_utils->get_publication_date($playlist) . "\n\n" . '<i>'
+ . encode_entities($row_description)
+ . '</i>');
+
+ my $num_items_template = "<b>$symbols{numero}</b> %d items\n";
+ my $num_items_text = "";
+
+ if (defined($playlist->{contentDetails}{itemCount})) {
+ $num_items_text = sprintf($num_items_template, $playlist->{contentDetails}->{itemCount});
+ }
+
+ my $type_label = reflow_text("<b>$symbols{diamond}</b> " . '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::StrawViewer::GetCaption;
+ my $yv_cap = WWW::StrawViewer::GetCaption->new(
+ auto_captions => $CONFIG{auto_captions},
+ captions_dir => $CONFIG{captions_dir},
+ captions => $captions,
+ languages => $CONFIG{srt_languages},
+ );
+ $srt_file = $yv_cap->save_caption($video_id);
+ }
+
+ require WWW::StrawViewer::Itags;
+ state $yv_itags = WWW::StrawViewer::Itags->new();
+
+ my ($streaming, $resolution) =
+ $yv_itags->find_streaming_url(
+ urls => $urls,
+ resolution => $CONFIG{resolution},
+ 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{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{ --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) // return;
+ my $type = $cats_liststore->get($iter, 3);
+
+ my $videos =
+ $type eq 'edu-cat'
+ ? $yv_obj->get_video_lectures_from_category($cat_id)
+ : $yv_obj->videos_from_category($cat_id);
+
+ if (not $yv_utils->has_entries($videos)) {
+ $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_youtube_viewer {
+ execute_cli_youtube_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,
+ 'resolution=audio' => $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_youtube_viewer('--video-ids=' . join(q{,}, splice @VIDEO_QUEUE));
+ }
+ return 1;
+}
+
+sub play_selected_video_with_cli_youtube_viewer {
+ my ($code, $iter) = get_selected_entry_code();
+ $code // return;
+
+ my $type = $liststore->get($iter, 7);
+
+ if ($type eq 'video') {
+ execute_cli_youtube_viewer("--video-id=$code");
+ }
+ elsif ($type eq 'playlist') {
+ execute_cli_youtube_viewer("--pp=$code");
+ }
+ else {
+ warn "Can't play $type: $code\n";
+ }
+
+ return 1;
+}
+
+sub execute_cli_youtube_viewer {
+ my @arguments = @_;
+
+ my $command = join(
+ q{ },
+ $CONFIG{terminal},
+ sprintf(
+ $CONFIG{terminal_exec},
+ join(q{ },
+ $CONFIG{youtube_viewer}, get_options_as_arguments(),
+ @arguments, @{$CONFIG{youtube_viewer_args}}),
+ )
+ );
+ my $code = execute_external_program($command);
+
+ say $command if $yv_obj->get_debug;
+
+ warn "youtube-viewer - exit code: $code\n" if $code != 0;
+ return 1;
+}
+
+sub download_video {
+ my $code = get_selected_entry_code(type => 'video') // return;
+ execute_cli_youtube_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($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 $res = $results->{results} // {};
+ my $comments = $res->{items} // [];
+
+ foreach my $comment (@{$comments}) {
+ my $snippet = (($comment->{snippet} // next)->{topLevelComment} // next)->{snippet};
+ my $comment_age = $yv_utils->date_to_age($snippet->{publishedAt});
+ my $comment_text = reflow_text(
+ "<big><b>"
+ . encode_entities($snippet->{authorDisplayName})
+ . "</b> ("
+ . (
+ $comment_age =~ /sec|min|hour|day/
+ ? "$comment_age ago"
+ : $yv_utils->format_date($snippet->{publishedAt})
+ )
+ . ") commented:</big>\n"
+ . encode_entities(
+ wrap_text(
+ i_tab => "\t",
+ s_tab => "\t",
+ text => [$snippet->{textDisplay} // 'Empty comment...'],
+ )
+ )
+ );
+
+ my $iter = $feeds_liststore->append;
+ $feeds_liststore->set(
+ $iter,
+ 0 => $comment_text,
+ 3 => $snippet->{videoId},
+ 4 => $comment->{snippet}{topLevelComment}{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<big><b>"
+ . encode_entities($reply->{snippet}{authorDisplayName})
+ . "</b> ("
+ . (
+ $reply_age =~ /sec|min|hour|day/
+ ? "$reply_age ago"
+ : $yv_utils->format_date($reply->{snippet}{publishedAt})
+ )
+ . ") replied:</big>\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 (exists $res->{nextPageToken}) {
+ my $iter = $feeds_liststore->append;
+ $feeds_liststore->set(
+ $iter,
+ 0 => "<big><b>LOAD MORE</b></big>",
+ 1 => $url,
+ 2 => $res->{nextPageToken},
+ );
+ }
+
+ 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_max};
+ 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, '</big>') + 6, '');
+ $gui->get_object('video_title_label')->set_label("<big>$title</big>");
+ $gui->get_object('video_title_label')->set_tooltip_markup("$title");
+
+ # Setting video details
+ $main_details =~ s/^\s+//;
+ $main_details =~ s{\s*<i>.+</i>\s*}{\n};
+ $main_details =~ s{\h+}{ }g;
+ $main_details =~ s{^.*?<b>.*?</b>\K\h*}{\t}gm;
+
+ my $secondary_details = $liststore->get($iter, 2);
+ $secondary_details =~ s{\h+}{ }g;
+ $secondary_details =~ s{^.*?<b>.*?</b>\K\h*}{\t}gm;
+ $secondary_details .= "\n$symbols{black_face}\t$channel_id";
+
+ 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));
+
+ # Getting thumbs
+ foreach my $nr (qw(1 2 3)) {
+
+ $gui->get_object("image$nr")->set_from_pixbuf($default_thumb);
+
+ Glib::Idle->add(
+ sub {
+ my ($nr) = @{$_[0]};
+
+ if ($code =~ /$valid_video_id_re/) {
+
+ 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$nr.$1};
+ }
+
+ my $pixbuf = get_pixbuf_thumbnail_from_url($url, 160, 90);
+ $gui->get_object("image$nr")->set_from_pixbuf($pixbuf);
+ }
+ else {
+ $gui->get_object("image$nr")->set_from_pixbuf($default_thumb);
+ }
+
+ return 0;
+ },
+ [$nr],
+ 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/straw-viewer b/bin/straw-viewer
new file mode 100755
index 0000000..ef3b6f2
--- /dev/null
+++ b/bin/straw-viewer
@@ -0,0 +1,4230 @@
+#!/usr/bin/perl
+
+# Copyright (C) 2010-2020 Trizen <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d>.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of either: the GNU General Public License as published
+# 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.
+#
+#-------------------------------------------------------
+# straw-viewer
+# Fork: 14 February 2020
+# Edit: 14 February 2020
+# https://github.com/trizen/straw-viewer
+#-------------------------------------------------------
+
+# straw-viewer is a command line utility for streaming YouTube videos in mpv/vlc/mplayer.
+
+# This is a fork of youtube-viewer:
+# https://github.com/trizen/youtube-viewer
+
+=head1 NAME
+
+straw-viewer - YouTube from command line.
+
+See: straw-viewer --help
+ straw-viewer --tricks
+ straw-viewer --examples
+ straw-viewer --stdin-help
+
+=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<https://dev.perl.org/licenses/> for more information.
+
+=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::StrawViewer v3.7.4;
+use WWW::StrawViewer::RegularExpressions;
+
+use File::Spec::Functions qw(
+ catdir
+ catfile
+ curdir
+ path
+ rel2abs
+ tmpdir
+ file_name_is_absolute
+ );
+
+binmode(STDOUT, ':utf8');
+
+my $appname = 'CLI Straw Viewer';
+my $version = $WWW::StrawViewer::VERSION;
+my $execname = 'straw-viewer';
+
+# A better <STDIN> support:
+require Term::ReadLine;
+my $term = Term::ReadLine->new("$appname $version");
+
+# Developer key
+my $key = 'aXalQYmzI8gPkMSLyMhpApfMAiU2b23Qz2nE3mq';
+
+sub VIDEO_PART () { 'contentDetails,statistics,snippet' }
+
+# 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, '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 --title=*TITLE* --no-ytdl},
+ novideo => q{--no-video},
+ },
+ mplayer => {
+ cmd => q{mplayer},
+ srt => q{-sub *SUB*},
+ audio => q{-audiofile *AUDIO*},
+ fs => q{-fs},
+ arg => q{-prefer-ipv4 -really-quiet -title *TITLE*},
+ novideo => q{-novideo},
+ },
+ },
+
+ video_player_selected => (
+ $constant{win32}
+ ? 'mplayer'
+ : undef # auto-defined
+ ),
+
+ # YouTube options
+ dash_support => 1,
+ dash_mp4_audio => 1,
+ dash_segmented => 1, # may load slow
+ maxResults => 20,
+ resolution => 'best',
+ videoDefinition => undef,
+ videoDimension => undef,
+ videoLicense => undef,
+ safeSearch => undef,
+ videoCaption => undef,
+ videoDuration => undef,
+ videoSyndicated => undef,
+ publishedBefore => undef,
+ publishedAfter => undef,
+ order => undef,
+
+ comments_order => 'time', # valid values: time, relevance
+ subscriptions_order => 'relevance', # valid values: alphabetical, relevance, unread
+
+ hl => 'en_US',
+ regionCode => undef,
+
+ # URI options
+ youtube_video_url => 'https://www.youtube.com/watch?v=%s',
+
+ # Subtitle options
+ srt_languages => ['en', 'es'],
+ captions_dir => tmpdir(),
+ get_captions => 1,
+ auto_captions => 0,
+ copy_caption => 0,
+ cache_dir => undef, # auto-defined
+
+ # Others
+ autoplay_mode => 0,
+ use_invidious_api => 0,
+ http_proxy => undef,
+ env_proxy => 1,
+ confirm => 0,
+ debug => 0,
+ page => 1,
+ colors => $constant{win32} ^ 1,
+ skip_if_exists => 1,
+ prefer_mp4 => 0,
+ prefer_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,
+ autohide_watched => 0,
+ skip_watched => 0,
+ remember_watched => 0,
+ watched_file => $watched_file,
+ highlight_watched => 1,
+ highlight_color => 'bold',
+ remove_played_file => 0,
+ history => 0,
+ history_limit => 100_000,
+ history_file => $history_file,
+ convert_cmd => 'ffmpeg -i *IN* *OUT*',
+ convert_to => undef,
+
+ 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
+<number> : 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 ($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, 'youtube-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';
+ }
+ }
+ elsif ($CONFIG{video_player_selected} =~ /mpv/i) { # update for mpv 0.32 (#290)
+#<<<
+ my $mpv = $CONFIG{video_players}{$CONFIG{video_player_selected}};
+ if (
+ ($mpv->{arg} =~ s/(--title)\s+(\*TITLE\*)/$1=$2/g)
+ | ($mpv->{audio} =~ s/(--audio-file)\s+(\*AUDIO\*)/$1=$2/g)
+ | ($mpv->{srt} =~ s/(--sub-file)\s+(\*SUB\*)/$1=$2/g)
+ ) {
+ say ":: Updated configuration to support mpv 0.32";
+ $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;
+ }
+
+ foreach my $key (keys %CONFIG) {
+ if (not exists $CONFIG->{$key}) {
+ $update_config = 1;
+ last;
+ }
+ }
+
+ dump_configuration($config_file) if $update_config;
+
+ # Create the cache directory (if needed)
+ if (not -d $CONFIG{cache_dir}) {
+ require File::Path;
+ File::Path::make_path($CONFIG{cache_dir})
+ or warn "[!] Can't create dir `$CONFIG{cache_dir}': $!";
+ }
+
+ @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}) {
+ open my $fh, '>', $opt{history_file}
+ or warn "[!] Can't create the history file `$opt{history_file}': $!";
+ }
+
+ # Add history to Term::ReadLine
+ $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 $i = length $key;
+ $key =~ s/(.{$i})(.)/$2$1/g while --$i;
+}
+
+my $yv_obj = WWW::StrawViewer->new(
+ escape_utf8 => 1,
+ key => $key,
+ config_dir => $config_dir,
+ cache_dir => $opt{cache_dir},
+ lwp_env_proxy => $opt{env_proxy},
+ use_invidious_api => $opt{use_invidious_api},
+ authentication_file => $authentication_file,
+ );
+
+{
+ $yv_obj->set_client_id('923751928481.apps.googleusercontent.com');
+ $yv_obj->set_client_secret("\26/Ae]3\b\6\x186a:*#0\32\t\f\n\27\17GC`" ^ substr($key, -24));
+ $yv_obj->set_redirect_uri('urn:ietf:wg:oauth:2.0:oob');
+}
+
+if (-f $api_file) {
+
+ open(my $fh, '<', $api_file) or die "[!] Can't open file <<$api_file>> for reading: $!\n";
+ my $content = do { local $/; <$fh> };
+ my $api = $yv_obj->parse_json_string($content);
+
+ if (ref($api) ne 'HASH') {
+ die "[!] Invalid format inside file 'api.json'.\n";
+ }
+
+ my $orig_key = $yv_obj->get_key;
+ my $orig_client_id = $yv_obj->get_client_id;
+ my $orig_client_secret = $yv_obj->get_client_secret;
+
+ my $key = $api->{key};
+ my $client_id = $api->{client_id};
+ my $client_secret = $api->{client_secret};
+
+ if (defined($key)) {
+ $yv_obj->set_key($key) // do {
+ warn "[!] Invalid key: $key\n" if $key ne 'API_KEY';
+ $yv_obj->set_key($orig_key);
+ };
+ }
+ if (defined($client_id)) {
+ $yv_obj->set_client_id($client_id) // do {
+ warn "[!] Invalid client_id: $client_id\n" if $client_id ne 'CLIENT_ID';
+ $yv_obj->set_client_id($orig_client_id);
+ };
+ }
+ if (defined($client_secret)) {
+ $yv_obj->set_client_secret($client_secret) // do {
+ warn "[!] Invalid client_secret: $client_secret\n" if $client_secret ne 'CLIENT_SECRET';
+ $yv_obj->set_client_secret($orig_client_secret);
+ };
+ }
+}
+else {
+ open(my $fh, '>', $api_file) or warn "[!] Can't create file <<$api_file>>: $!\n";
+ print $fh <<"EOT";
+{
+ "key": "API_KEY",
+ "client_id": "CLIENT_ID",
+ "client_secret": "CLIENT_SECRET"
+}
+EOT
+ close $fh;
+}
+
+$yv_obj->load_authentication_tokens();
+
+require WWW::StrawViewer::Utils;
+my $yv_utils = WWW::StrawViewer::Utils->new(youtube_url_format => $opt{youtube_video_url},
+ thousand_separator => $opt{thousand_separator},);
+
+{ # Apply the configuration file
+ my %temp = %CONFIG;
+ apply_configuration(\%temp);
+}
+
+#---------------------- YOUTUBE-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
+ -hl --catlang=s : language for categories (default: en_US)
+
+ * 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 videos favorited by 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 ID or name
+ use the `--within=s` option to restrict the results
+
+ * 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 are: short medium long
+ --caption=s : only videos with/without closed captions
+ valid values are: any closedCaption none
+ --category=s : search only for videos in a specific category name/ID
+ --safe-search=s : YouTube will skip restricted videos for your location
+ valid values are: none moderate strict
+ --order=s : order the results using a specific sorting method
+ valid values: date rating viewCount title videoCount
+ --within=s : show only videos uploaded within the specified time
+ valid values are: Nd, Nw, Nm, Ny, where N is a number
+ --hd! : search only for videos available in at least 720p
+ --vd=s : set the video definition (any, high or standard)
+ --page=i : get results starting with a specific page number
+ --results=i : how many results to display per page (max: 50)
+ -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
+ --autohide! : automatically hide watched videos
+ --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
+
+ * Closed-captions
+ --get-captions! : download closed-captions for videos
+ --auto-captions! : include or exclude auto-generated captions
+ --captions-dir=s : directory where to save the .srt files
+
+ * 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
+ --invidious! : use the API of invidio.us to get the streaming URLs
+ --proxy=s : set HTTP(S)/SOCKS proxy: 'proto://domain.tld:port/'
+ If authentication 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
+
+
+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";
+
+ == straw-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 --author=MIT atom
+Results: search only in videos by a specific author.
+
+Command: $execname --author=MIT atom --within=2y
+Results: search only in videos by a specific author, published in the last 2 years.
+
+Command: $execname --popular=MIT --within=6m
+Results: show the most popular videos by a specific user, published in the last 6 months.
+
+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=viewCount --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: <ENTER>, :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 maxResults order
+ videoDefinition videoCategoryId
+ videoDimension videoDuration
+ videoEmbeddable videoLicense
+ videoSyndicated channelId
+ publishedAfter publishedBefore
+ safeSearch regionCode debug hl
+ http_proxy page comments_order
+ subscriptions_order use_invidious_api
+ )
+ ) {
+
+ if (defined $opt->{$option_name}) {
+ my $code = \&{"WWW::StrawViewer::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->{category_id}) {
+
+ my $str = delete $opt->{category_id};
+ my $category = extract_category($str);
+
+ if (ref($category) eq 'HASH') {
+ say ":: Category selected: $category->{snippet}{title}" if $yv_obj->get_debug;
+ $yv_obj->set_videoCategoryId($category->{id});
+ }
+ else {
+ warn_invalid('category', $str);
+ }
+ }
+
+ if (defined $opt->{prefer_mp4}) {
+ $yv_obj->set_prefer_mp4(delete($opt->{prefer_mp4}) ? 1 : 0);
+ }
+
+ if (defined $opt->{prefer_av1}) {
+ $yv_obj->set_prefer_av1(delete($opt->{prefer_av1}) ? 1 : 0);
+ }
+
+ 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->{within}) {
+ my $value = delete $opt->{within};
+
+ if ($value =~ /^\s*(\d+(?:\.\d+)?)([dwmy])/i) {
+ my $date = $yv_utils->period_to_date($1, $2);
+ $yv_obj->set_publishedAfter($date);
+ }
+ else {
+ warn "\n[!] Invalid value <$value> for option `--within`!\n";
+ }
+ }
+
+ 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 $str = delete $opt->{trending};
+ my $category = extract_category($str);
+ my $cat_id = undef;
+
+ if (ref($category) eq 'HASH') {
+ say ":: Category selected: $category->{snippet}{title}" if $yv_obj->get_debug;
+ $cat_id = $category->{id};
+ }
+ elsif ($str) {
+ warn_invalid('category', $str);
+ }
+
+ 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 },
+ '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},
+
+ 'http_proxy|http-proxy|proxy=s' => \$opt{http_proxy},
+
+ 'catlang|cl|hl=s' => \$opt{hl},
+ 'category|cat-id|cat=s' => \$opt{category_id},
+ 'r|region|region-code=s' => \$opt{regionCode},
+
+ 'orderby|order|order-by=s' => \$opt{order},
+ 'duration=s' => \$opt{videoDuration},
+ 'within=s' => \$opt{within},
+
+ '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},
+ 'safe-search|safeSearch=s' => \$opt{safeSearch},
+ '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},
+ 'caption=s' => \$opt{videoCaption},
+ 'fullscreen|fs|f!' => \$opt{fullscreen},
+ 'dash!' => \$opt{dash_support},
+ 'confirm!' => \$opt{confirm},
+
+ 'prefer-mp4!' => \$opt{prefer_mp4},
+ 'prefer-av1!' => \$opt{prefer_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},
+
+ 'invidious!' => \$opt{use_invidious_api},
+ '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},
+ 'autohide!' => \$opt{autohide_watched},
+ '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},
+
+ '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},
+ 'captions-dir|captions_dir=s' => \$opt{captions_dir},
+ '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 <STDIN>));
+}
+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_category {
+ my ($str) = @_;
+
+ $str || return;
+
+ state $results = $yv_obj->video_categories;
+ return if ref($results) ne 'HASH';
+
+ my $categories = $results->{items};
+ return if ref($categories) ne 'ARRAY';
+
+ foreach my $category (@$categories) {
+ if ($category->{id} eq $str) {
+ return $category;
+ }
+ }
+
+ my $str_re = qr/\Q$str\E/i;
+
+ foreach my $category (@$categories) {
+ if ($yv_utils->get_title($category) =~ /$str_re/) {
+ return $category;
+ }
+ }
+
+ return;
+}
+
+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 mplayer
+sub get_mplayer {
+ if ($constant{win32}) {
+ my $smplayer = catfile($ENV{ProgramFiles}, qw(SMPlayer mplayer mplayer.exe));
+
+ if (not -e $smplayer) {
+ warn "\n\n!!! Please install SMPlayer in order to stream YouTube videos.\n\n";
+ }
+
+ return $smplayer; # Windows MPlayer
+ }
+
+ return 'mplayer'; # *NIX MPlayer
+}
+
+# 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})
+ : ($term->readline($text) // return ':return')
+ ) =~ s/^\s+//r;
+
+ return q{:next} if $input eq q{}; # <ENTER> 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,2}\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 { get_valid_video_id($_) } @_) || return;
+ my $info = $yv_obj->video_details(join(',', @ids), VIDEO_PART);
+
+ if ($yv_utils->has_entries($info)) {
+ if (not play_videos($info->{results}{items})) {
+ return;
+ }
+ }
+ else {
+ warn_cant_do('get info for', @ids);
+ }
+
+ 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, VIDEO_PART);
+
+ if ($yv_utils->has_entries($info)) {
+ local $opt{show_video_info} = 1;
+ print_video_info($info->{results}{items}[0]);
+ }
+ 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};
+
+ if (not defined($option)) {
+ return;
+ }
+
+ if ($option =~ /^(?:q|quit|exit)\z/) {
+ main_quit(0);
+ }
+ elsif ($option =~ /^(?:n|next)\z/ and defined $url) {
+ if (defined $info->{nextPageToken}) {
+ my $request = $yv_obj->next_page($url, $info->{nextPageToken});
+ $callback->($request);
+ }
+ else {
+ warn_last_page();
+ }
+ }
+ elsif ($option =~ /^(?:R|refresh)\z/ and defined $url) {
+ @{$results} = @{$yv_obj->_get_results($url)->{results}{items}};
+ }
+ elsif ($option =~ /^(?:b|back|p|prev|previous)\z/ and defined $url) {
+ if (defined $info->{prevPageToken}) {
+ my $request = $yv_obj->previous_page($url, $info->{prevPageToken});
+ $callback->($request);
+ }
+ else {
+ warn_first_page();
+ }
+ }
+ 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 <ENTER> 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,2}\z/ && $num != 0 && $num <= @{$array_ref};
+}
+
+sub adjust_width {
+ my ($str, $len, $prepend) = @_;
+
+ $len > 0 or do {
+ warn "[WARN] Insufficient space for the title: increase your terminal width!\n";
+ 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 $info = $results->{results} // {};
+ my $channels = $info->{items} // [];
+
+ 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_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_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_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_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 => $info,
+ )
+ ) {
+ ## 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 $info = $results->{results} // {};
+ my $comments = $info->{items} // [];
+
+ my $i = 0;
+ foreach my $comment (@{$comments}) {
+ my $snippet = (($comment->{snippet} // next)->{topLevelComment} // next)->{snippet};
+ my $comment_age = $yv_utils->date_to_age($snippet->{publishedAt});
+
+ printf(
+ "\n%s (%s) commented:\n%s\n",
+ colored($snippet->{authorDisplayName}, 'bold'),
+ (
+ $comment_age =~ /sec|min|hour|day/
+ ? "$comment_age ago"
+ : $yv_utils->format_date($snippet->{publishedAt})
+ ),
+ wrap_text(
+ i_tab => q{ } x 3,
+ s_tab => q{ } x 3,
+ text => [$snippet->{textDisplay} // '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 => $info,
+ 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) = @_;
+
+ return if ref($results) ne 'HASH';
+ my $categories = $results->{items};
+ return if ref($categories) ne 'ARRAY';
+
+ my $i = 0;
+ print "\n" if @{$categories};
+
+ # Filter out nonassignable categories
+ @$categories = grep { $_->{snippet}{assignable} } @$categories;
+
+ foreach my $category (@{$categories}) {
+ printf "%s. %-40s (id: %s)\n", colored(sprintf('%2d', ++$i), 'bold'), $yv_utils->get_title($category), $category->{id};
+ }
+
+ 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->videos_from_category($cat_id);
+
+ if (not $yv_utils->has_entries($videos)) {
+ $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 $info = $results->{results} // {};
+ my $playlists = $info->{items} // [];
+
+ 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 => $info,
+ 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) {
+ if ($args{return_playlist_id}) {
+ return $yv_utils->get_playlist_id($playlists->[$key - 1]);
+ }
+ get_and_print_videos_from_playlist($yv_utils->get_playlist_id($playlists->[$key - 1]));
+ }
+ else {
+ push @for_search, $key;
+ }
+ }
+
+ if (@for_search) {
+ __SUB__->($yv_obj->search_playlists(\@for_search));
+ }
+
+ __SUB__->(@_);
+}
+
+sub compile_regex {
+ my ($value) = @_;
+ $value =~ s{^(?<quote>['"])(?<regex>.+)\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,2}\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::StrawViewer::GetCaption;
+ my $yv_cap = WWW::StrawViewer::GetCaption->new(
+ auto_captions => $opt{auto_captions},
+ captions_dir => $opt{captions_dir},
+ captions => $captions,
+ languages => $CONFIG{srt_languages},
+ );
+ $srt_file = $yv_cap->save_caption($video_id);
+ }
+
+ require WWW::StrawViewer::Itags;
+ state $yv_itags = WWW::StrawViewer::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}),
+ 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*/ - /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 = "$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 from captions-dir 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{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{ --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}{items}}) || 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::StrawViewer::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]),
+ (
+ ['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))],
+ ['Comments' => $yv_utils->set_thousands($yv_utils->get_comments($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) = @_;
+
+ # use Data::Dump qw(pp);
+ # pp $results;
+
+ 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} // [];
+ #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 $info->{prevPageToken}) {
+ __SUB__->($yv_obj->previous_page($url), auto => 1);
+ #}
+ #else {
+ # $opt{play_backwards} = 0;
+ # warn_first_page();
+ # return;
+ #}
+ }
+ else {
+ #if (defined $info->{nextPageToken}) {
+ __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 $info->{nextPageToken}) {
+ 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 $info->{prevPageToken}) {
+ __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}{items}};
+ $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)) {
+ autoplay($yv_utils->get_video_id($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);
+ }
+
+ if ($opt{autohide_watched}) {
+ splice(@{$videos}, $key, 1) for @for_play;
+ }
+ }
+ 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 <ENTER> in $timeout seconds to stop the :anp option.", 'bold green');
+ eval {
+ local $SIG{ALRM} = sub {
+ die "alarm\n";
+ };
+ alarm $timeout;
+ scalar <STDIN>;
+ 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 {
+ scalar $term->readline(colored("\n=>> Press ENTER to continue...", 'bold'));
+}
+
+sub main_quit {
+ exit($_[0] // 0);
+}
+
+main_quit(0);
diff --git a/lib/WWW/StrawViewer.pm b/lib/WWW/StrawViewer.pm
new file mode 100644
index 0000000..ecd31c9
--- /dev/null
+++ b/lib/WWW/StrawViewer.pm
@@ -0,0 +1,1045 @@
+package WWW::StrawViewer;
+
+use utf8;
+use 5.016;
+use warnings;
+
+use parent qw(
+ WWW::StrawViewer::Search
+ WWW::StrawViewer::Videos
+ WWW::StrawViewer::Channels
+ WWW::StrawViewer::Playlists
+ WWW::StrawViewer::ParseJSON
+ WWW::StrawViewer::Activities
+ WWW::StrawViewer::Subscriptions
+ WWW::StrawViewer::PlaylistItems
+ WWW::StrawViewer::CommentThreads
+ WWW::StrawViewer::Authentication
+ WWW::StrawViewer::VideoCategories
+ );
+
+=head1 NAME
+
+WWW::StrawViewer - A very easy interface to YouTube.
+
+=cut
+
+our $VERSION = '3.7.4';
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+
+ my $yv_obj = WWW::StrawViewer->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},
+ hl => {valid => [qr/^\w+(?:[\-_]\w+)?\z/], default => undef},
+ maxResults => {valid => [1 .. 50], default => 10},
+ topicId => {valid => [qr/^./], default => undef},
+ order => {valid => [qw(relevance date rating viewCount title videoCount)], default => undef},
+ publishedAfter => {valid => [qr/^\d+/], default => undef},
+ publishedBefore => {valid => [qr/^\d+/], default => undef},
+ channelId => {valid => [qr/^[-\w]{2,}\z/], default => undef},
+ channelType => {valid => [qw(any show)], default => undef},
+
+ # Video only options
+ videoCaption => {valid => [qw(any closedCaption none)], default => undef},
+ videoDefinition => {valid => [qw(any high standard)], default => undef},
+ videoCategoryId => {valid => [qr/^\d+\z/], default => undef},
+ videoDimension => {valid => [qw(any 2d 3d)], default => undef},
+ videoDuration => {valid => [qw(any short medium long)], default => undef},
+ videoEmbeddable => {valid => [qw(any true)], default => undef},
+ videoLicense => {valid => [qw(any creativeCommon youtube)], default => undef},
+ videoSyndicated => {valid => [qw(any true)], default => undef},
+ eventType => {valid => [qw(completed live upcoming)], default => undef},
+ chart => {valid => [qw(mostPopular)], default => 'mostPopular'},
+
+ regionCode => {valid => [qr/^[A-Z]{2}\z/i], default => undef},
+ relevanceLanguage => {valid => [qr/^[a-z](?:\-\w+)?\z/i], default => undef},
+ safeSearch => {valid => [qw(none moderate strict)], default => undef},
+ videoType => {valid => [qw(any episode movie)], default => undef},
+
+ comments_order => {valid => [qw(time relevance)], default => 'time'},
+ subscriptions_order => {valid => [qw(alphabetical relevance unread)], default => undef},
+
+ # Misc
+ debug => {valid => [0 .. 3], default => 0},
+ lwp_timeout => {valid => [qr/^\d+\z/], default => 1},
+ config_dir => {valid => [qr/^./], default => q{.}},
+ cache_dir => {valid => [qr/^./], default => q{.}},
+
+ # Booleans
+ lwp_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},
+
+ use_invidious_api => {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},
+
+ # No input value allowed
+ feeds_url => {valid => q[], default => 'https://invidio.us/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'},
+
+ # LWP user agent
+ lwp_agent => {valid => [qr/^.{5}/], default => 'Mozilla/5.0 (X11; U; Linux i686; gzip; en-US) Chrome/10.0.648.45'},
+);
+
+sub _our_smartmatch {
+ my ($value, $arg) = @_;
+
+ $value // return 0;
+
+ if (ref($arg) eq '') {
+ 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;
+}
+
+{
+ no strict 'refs';
+
+ foreach my $key (keys %valid_options) {
+
+ if (ref $valid_options{$key}{valid} eq 'ARRAY') {
+
+ # 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;
+
+ # Don't generate the token for the first page
+ return undef if $page == 1;
+
+ my $index = $page * $self->get_maxResults() - $self->get_maxResults();
+ my $k = int($index / 128) - 1;
+ $index -= 128 * $k;
+
+ my @f = (8, $index);
+ if ($k > 0 or $index > 127) {
+ push @f, $k + 1;
+ }
+
+ require MIME::Base64;
+ MIME::Base64::encode_base64(pack('C*', @f, 16, 0)) =~ tr/=\n//dr;
+}
+
+=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' }
+ );
+
+ $self->{lwp} = $lwp->new(
+
+ timeout => $self->get_lwp_timeout,
+ show_progress => $self->get_debug,
+ agent => $self->get_lwp_agent,
+
+ ssl_opts => {verify_hostname => 1, SSL_version => 'TLSv1_2'},
+
+ $lwp eq 'LWP::UserAgent::Cached'
+ ? (
+ cache_dir => $self->get_cache_dir,
+ nocache_if => sub {
+ my ($response) = @_;
+ my $code = $response->code;
+
+ $code >= 500 # do not cache any bad response
+ or $code == 401 # don't cache an unauthorized 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_lwp_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();
+ };
+
+ my $agent = $self->{lwp};
+ $agent->ssl_opts(Timeout => 30);
+ $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);
+
+ push @{$self->{lwp}->requests_redirectable}, 'POST';
+ return $self->{lwp};
+}
+
+=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 =~ s/([&?])key=(.*?)&/${1}key=[...]&/r);
+}
+
+=head2 lwp_get($url, %opt)
+
+Get and return the content for $url.
+
+Where %opt can be:
+
+ simple => [bool]
+
+When the value of B<simple> 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);
+ }
+
+ _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 _simple_feeds_url {
+ my ($self, $suburl, %args) = @_;
+ $self->get_feeds_url() . $suburl . '?' . $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,
+ #regionCode => $self->get_regionCode,
+ %args,
+ );
+
+ $self->list_to_url_arguments(%defaults);
+}
+
+sub _make_feed_url {
+ my ($self, $path, %args) = @_;
+ $self->get_feeds_url() . $path . '?' . $self->default_arguments(%args);
+}
+
+sub _extract_from_invidious {
+ my ($self, $videoID) = @_;
+
+ my $url = sprintf("https://invidio.us/api/v1/videos/%s?fields=formatStreams,adaptiveFormats", $videoID);
+
+ my $tries = 3;
+ my $resp = $self->{lwp}->get($url);
+
+ while (not $resp->is_success() and $resp->status_line() =~ /read timeout/i and --$tries >= 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 _extract_from_ytdl {
+ my ($self, $videoID) = @_;
+
+ ((state $x = system('youtube-dl', '--version')) == 0) || return;
+
+ my $json = $self->proxy_stdout('youtube-dl', '--all-formats', '--dump-single-json',
+ 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;
+
+ if ($self->get_use_invidious_api) { # 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) {
+ say STDERR ":: Found ", scalar(@formats), " streaming URLs.";
+ }
+
+ @formats && return @formats;
+ }
+
+ 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 ":: 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<multi> 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 _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);
+
+ foreach my $video (@results) {
+ if (exists $video->{s}) { # 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;
+ }
+ }
+
+ 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},
+ };
+ }
+ }
+
+ if ($self->get_debug) {
+ my $count = scalar(@results);
+ say STDERR ":: Found $count streaming URLs...";
+ }
+
+ 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 (exists $streamingData->{adaptiveFormats}) {
+ push @results, @{$streamingData->{adaptiveFormats}};
+ }
+
+ if (exists $streamingData->{formats}) {
+ push @results, @{$streamingData->{formats}};
+ }
+ }
+
+ foreach my $item (@results) {
+
+ if (exists $item->{cipher} and not exists $item->{url}) {
+
+ my %data = $self->parse_query_string($item->{cipher});
+
+ $item->{url} = $data{url} if defined($data{url});
+
+ if (defined($data{s})) { # unclear how this can be decrypted...
+ require URI::Escape;
+ my $sig = $data{s};
+ $sig = URI::Escape::uri_escape($sig);
+ $item->{url} .= "&sig=$sig";
+ }
+ }
+
+ if (exists $item->{mimeType}) {
+ $item->{type} = $item->{mimeType};
+ }
+ }
+
+ # Cipher streaming URLs are currently unsupported, so let's filter them out.
+ @results = grep { not exists $_->{cipher} } @results;
+
+ # Keep only streams with contentLength > 0.
+ @results = grep { exists($_->{contentLength}) and $_->{contentLength} > 0 } @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},
+ };
+ }
+ }
+
+ if ($self->get_debug) {
+ my $count = scalar(@results);
+ say STDERR ":: Found $count streaming URLs...";
+ }
+
+ 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}) {
+
+ require URI::Escape;
+ my $captions_json = URI::Escape::uri_unescape($info{player_response});
+ 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}};
+ }
+ }
+
+ # 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::StrawViewer::Itags;
+
+ my %audio_itags;
+ @audio_itags{@{WWW::StrawViewer::Itags->get_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 ($self->get_prefer_mp4 and $url->{type} =~ /\bmp4\b/i) {
+ push @video_urls, $url;
+ }
+ elsif ($self->get_prefer_av1 and $url->{type} =~ /\bav[0-9]+\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);
+}
+
+# SUBROUTINE FACTORY
+{
+ no strict 'refs';
+
+ # Create {next,previous}_page subroutines
+ foreach my $name ('next_page', 'previous_page') {
+ *{__PACKAGE__ . '::' . $name} = sub {
+ my ($self, $url, $token) = @_;
+
+ my $pt_url = (
+ $url =~ s/[?&]pageToken=\K[^&]+/$token/
+ ? $url
+ : $self->_append_url_args($url, pageToken => $token)
+ );
+
+ my $res = $self->_get_results($pt_url);
+ $res->{url} = $pt_url;
+ return $res;
+ };
+ }
+
+ # 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');
+
+ $name eq 'exec' ? exec(@args)
+ : $name eq 'system' ? system(@args)
+ : $name eq 'stdout' ? qx(@args)
+ : ();
+ };
+ }
+}
+
+=head1 AUTHOR
+
+Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+=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<http://www.perlfoundation.org/artistic_license_2_0>
+
+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::StrawViewer
+
+__END__
diff --git a/lib/WWW/StrawViewer/Activities.pm b/lib/WWW/StrawViewer/Activities.pm
new file mode 100644
index 0000000..39a6a3d
--- /dev/null
+++ b/lib/WWW/StrawViewer/Activities.pm
@@ -0,0 +1,93 @@
+package WWW::StrawViewer::Activities;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::Activities - list of channel activity events that match the request criteria.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::Activities
diff --git a/lib/WWW/StrawViewer/Authentication.pm b/lib/WWW/StrawViewer/Authentication.pm
new file mode 100644
index 0000000..1fa2368
--- /dev/null
+++ b/lib/WWW/StrawViewer/Authentication.pm
@@ -0,0 +1,216 @@
+package WWW::StrawViewer::Authentication;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::Authentication - OAuth login support.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $hash_ref = WWW::StrawViewer->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<get_accounts_oauth_url()> 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<authentication_file>.
+
+=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<key> 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<key> 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<authentication_file>.
+
+=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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::Authentication
diff --git a/lib/WWW/StrawViewer/Channels.pm b/lib/WWW/StrawViewer/Channels.pm
new file mode 100644
index 0000000..d48c744
--- /dev/null
+++ b/lib/WWW/StrawViewer/Channels.pm
@@ -0,0 +1,190 @@
+package WWW::StrawViewer::Channels;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::Channels - Channels interface.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->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);
+}
+
+=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) = @_;
+
+ state $username_lookup = {};
+
+ if (exists $username_lookup->{$username}) {
+ return $username_lookup->{$username};
+ }
+
+ $username_lookup->{$username} = undef;
+ my $channel = $self->channels_from_username($username) // return;
+ $username_lookup->{$username} = $channel->{results}{items}[0]{id} // return;
+}
+
+=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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::Channels
diff --git a/lib/WWW/StrawViewer/CommentThreads.pm b/lib/WWW/StrawViewer/CommentThreads.pm
new file mode 100644
index 0000000..499d930
--- /dev/null
+++ b/lib/WWW/StrawViewer/CommentThreads.pm
@@ -0,0 +1,103 @@
+package WWW::StrawViewer::CommentThreads;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::CommentThreads - Retrieve comments threads.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->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) = @_;
+ return
+ $self->_get_results(
+ $self->_make_commentThreads_url(
+ videoId => $video_id,
+ textFormat => 'plainText',
+ order => $self->get_comments_order,
+ part => 'snippet,replies'
+ ),
+ simple => 1,
+ );
+}
+
+=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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::CommentThreads
diff --git a/lib/WWW/StrawViewer/GetCaption.pm b/lib/WWW/StrawViewer/GetCaption.pm
new file mode 100644
index 0000000..81af4e2
--- /dev/null
+++ b/lib/WWW/StrawViewer/GetCaption.pm
@@ -0,0 +1,280 @@
+package WWW::StrawViewer::GetCaption;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::GetCaption - Save the YouTube closed captions as .srt files for a videoID.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer::GetCaption;
+
+ my $yv_cap = WWW::StrawViewer::GetCaption->new(%opts);
+
+ print $yv_cap->get_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)];
+
+ 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 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::StrawViewer::ParseXML;
+ my $hash = eval { WWW::StrawViewer::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) = @_;
+
+ state $lwp = do {
+
+ require LWP::UserAgent;
+
+ my $agent = LWP::UserAgent->new(
+ timeout => 30,
+ env_proxy => 1,
+ agent =>
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.101 Safari/537.36',
+ );
+
+ 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 => 30);
+ $agent->default_header('Accept-Encoding' => $accepted_encodings);
+ $agent->conn_cache($cache);
+
+ $agent;
+ };
+
+ my $req = $lwp->get($url);
+
+ if ($req->is_success) {
+ return $req->decoded_content;
+ }
+
+ return;
+}
+
+=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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::GetCaption
diff --git a/lib/WWW/StrawViewer/GuideCategories.pm b/lib/WWW/StrawViewer/GuideCategories.pm
new file mode 100644
index 0000000..1f164b1
--- /dev/null
+++ b/lib/WWW/StrawViewer/GuideCategories.pm
@@ -0,0 +1,85 @@
+package WWW::StrawViewer::GuideCategories;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::GuideCategories - Categories interface.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->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{regionCode} //= $self->get_regionCode;
+ }
+
+ $self->_make_feed_url('guideCategories', hl => $self->get_hl, %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 => 'regionCode',
+ 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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::GuideCategories
diff --git a/lib/WWW/StrawViewer/Itags.pm b/lib/WWW/StrawViewer/Itags.pm
new file mode 100644
index 0000000..95ed483
--- /dev/null
+++ b/lib/WWW/StrawViewer/Itags.pm
@@ -0,0 +1,319 @@
+package WWW::StrawViewer::Itags;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::Itags - Get the YouTube itags.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer::Itags;
+
+ my $yv_itags = WWW::StrawViewer::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' => [
+ 38, # mp4 (3072p) (v-a)
+ [138, # mp4 (2160p-4320p) (v)
+ 266, # mp4 (2160p-2304p) (v)
+ ],
+ ],
+
+ '2160' => [
+ [
+ 315, # webm HFR (v)
+ 272, # webm (v)
+ 313, # webm (v)
+ 401, # av1 (v)
+ ],
+ ],
+
+ '1440' => [
+ [
+ 308, # webm HFR (v)
+ 271, # webm (v)
+ 264, # mp4 (v)
+ 400, # av1 (v)
+ ],
+ ],
+
+ '1080' => [
+ [303, # webm HFR (v)
+ 299, # mp4 HFR (v)
+ ],
+ 46, # webm (v-a)
+ 37, # mp4 (v-a)
+ [248, # webm (v)
+ 137, # mp4 (v)
+ 399, # av1 (v)
+ ],
+ 301, # mp4 (live) (v-a)
+ 96, # ts (live) (v-a)
+ ],
+
+ '720' => [
+ [302, # webm HFR (v)
+ 298, # mp4 HFR (v)
+ ],
+ 45, # webm (v-a)
+ 22, # mp4 (v-a)
+ [247, # webm (v)
+ 136, # mp4 (v)
+ 398, # av1 (v)
+ ],
+ 300, # mp4 (live) (v-a)
+ 120, # flv (live) (v-a)
+ 95, # ts (live) (v-a)
+ ],
+
+ '480' => [
+ 44, # webm (v-a)
+ 35, # flv (v-a)
+ [244, # webm (v)
+ 135, # mp4 (v)
+ 397, # av1 (v)
+ ],
+ 94, # mp4 (live) (v-a)
+ ],
+
+ '360' => [
+ 43, # webm (v-a)
+ 34, # flv (v-a)
+ [243, # webm (v)
+ 134, # mp4 (v)
+ 396, # av1 (v)
+ ],
+ 93, # mp4 (live) (v-a)
+ 18, # mp4 (v-a)
+ ],
+
+ '240' => [
+ 6, # flv (270p) (v-a)
+ 5, # flv (v-a)
+ 36, # 3gp (v-a)
+ 13, # 3gp (v-a)
+ [242, # webm (v)
+ 133, # mp4 (v)
+ 395, # av1 (v)
+ ],
+ 92, # mp4 (live) (v-a)
+ 132, # ts (live) (v-a)
+ ],
+
+ '144' => [
+ 17, # 3gp (v-a)
+ [278, # webm (v)
+ 160, # mp4 (v)
+ 394, # av1 (v)
+ ],
+ 91, # mp4 (live) (v-a)
+ 151, # ts (live) (v-a)
+ ],
+
+ 'audio' => [172, # webm (192 kbps)
+ 251, # webm opus (128-160 kbps)
+ 171, # webm vorbis (92-128 kbps)
+ 140, # mp4a (128 kbps)
+ 141, # mp4a (256 kbps)
+ 250, # webm opus (64 kbps)
+ 249, # webm opus (48 kbps)
+ 139, # 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}}) {
+
+ if (ref($itag) eq 'ARRAY') {
+
+ $args{dash} || next;
+
+ foreach my $i (@{$itag}) {
+
+ next if not exists $stream->{$i};
+
+ my $video_info = $stream->{$i};
+ 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 (exists $stream->{$itag}) {
+ if ($resolution eq 'audio' and not $args{dash_mp4_audio}) {
+ if ($itag == 140 or $itag == 141 or $itag == 139) {
+ next; # skip mp4 audio URLs
+ }
+ }
+
+ my $entry = $stream->{$itag};
+
+ # Ignore segmented DASH URLs (they load pretty slow in mpv)
+ if (not $args{dash_segmented}) {
+ next if ($entry->{url} =~ m{^https://manifest\.googlevideo\.com/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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::Itags
diff --git a/lib/WWW/StrawViewer/ParseJSON.pm b/lib/WWW/StrawViewer/ParseJSON.pm
new file mode 100644
index 0000000..e2e7d65
--- /dev/null
+++ b/lib/WWW/StrawViewer/ParseJSON.pm
@@ -0,0 +1,76 @@
+package WWW::StrawViewer::ParseJSON;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::ParseJSON - Parse JSON content.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer::ParseJSON;
+ my $obj = WWW::StrawViewer::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_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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::ParseJSON
diff --git a/lib/WWW/StrawViewer/ParseXML.pm b/lib/WWW/StrawViewer/ParseXML.pm
new file mode 100644
index 0000000..b6039b1
--- /dev/null
+++ b/lib/WWW/StrawViewer/ParseXML.pm
@@ -0,0 +1,311 @@
+package WWW::StrawViewer::ParseXML;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=encoding utf8
+
+=head1 NAME
+
+WWW::StrawViewer::ParseXML - Convert XML to a HASH ref structure.
+
+=head1 SYNOPSIS
+
+Parse XML content and return an HASH ref structure.
+
+Usage:
+
+ use WWW::StrawViewer::ParseXML;
+ my $hash_ref = WWW::StrawViewer::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<!\[CDATA\[(.*?)\]\]>\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<!\[CDATA\[(.*?)\]\]>\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<!--/gc) {
+ $xml =~ /\G.*?-->\s*/gcs or die "Comment not closed!";
+ redo;
+ }
+ elsif ($xml =~ /\G<!DOCTYPE\s+/gc) {
+ $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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::ParseXML
diff --git a/lib/WWW/StrawViewer/PlaylistItems.pm b/lib/WWW/StrawViewer/PlaylistItems.pm
new file mode 100644
index 0000000..046e065
--- /dev/null
+++ b/lib/WWW/StrawViewer/PlaylistItems.pm
@@ -0,0 +1,167 @@
+package WWW::StrawViewer::PlaylistItems;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::PlaylistItems - Manage playlist entries.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->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) = @_;
+ return $self->_get_results($self->_make_playlistItems_url(playlistId => $id, part => 'contentDetails,snippet'));
+}
+
+=head2 videos_from_id($playlist_id)
+
+Get videos from a specific playlistID.
+
+=cut
+
+sub playlists_from_id {
+ my ($self, $id) = @_;
+ return $self->_get_results($self->_make_playlistItems_url(id => $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) = @_;
+ my $playlist_id = $self->get_playlist_id(
+ $name, $username
+ ? (forUsername => $username)
+ : do { $self->get_access_token() // return; (mine => 'true') }
+ ) // return;
+ $self->videos_from_playlist_id($playlist_id);
+ };
+
+ *{__PACKAGE__ . '::' . $name} = sub {
+ my ($self, $channel_id) = @_;
+ my $playlist_id = $self->get_playlist_id(
+ $name, ($channel_id and $channel_id ne 'mine')
+ ? (id => $channel_id)
+ : do { $self->get_access_token() // return; (mine => 'true') }
+ ) // return;
+ $self->videos_from_playlist_id($playlist_id);
+ };
+ }
+}
+
+=head1 AUTHOR
+
+Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::PlaylistItems
diff --git a/lib/WWW/StrawViewer/Playlists.pm b/lib/WWW/StrawViewer/Playlists.pm
new file mode 100644
index 0000000..5d4e07d
--- /dev/null
+++ b/lib/WWW/StrawViewer/Playlists.pm
@@ -0,0 +1,124 @@
+package WWW::StrawViewer::Playlists;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::Playlists - Straw playlists handle.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->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',
+ pageToken => $self->page_token,
+ %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_playlists_url(
+ ($channel_id and $channel_id ne 'mine')
+ ? (channelId => $channel_id)
+ : do { $self->get_access_token() // return; (mine => 'true') }
+ )
+ );
+}
+
+=head2 playlists_from_username($username)
+
+Get and return the playlists created for a given username.
+
+=cut
+
+sub playlists_from_username {
+ my ($self, $username) = @_;
+ my $channel_id = $self->channel_id_from_username($username) // $username;
+ $self->playlists($channel_id);
+}
+
+=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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::Playlists
diff --git a/lib/WWW/StrawViewer/RegularExpressions.pm b/lib/WWW/StrawViewer/RegularExpressions.pm
new file mode 100644
index 0000000..edf9dd5
--- /dev/null
+++ b/lib/WWW/StrawViewer/RegularExpressions.pm
@@ -0,0 +1,89 @@
+package WWW::StrawViewer::RegularExpressions;
+
+use utf8;
+use 5.014;
+use warnings;
+
+require Exporter;
+our @ISA = qw(Exporter);
+
+=head1 NAME
+
+WWW::StrawViewer::RegularExpressions - Various utils.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer::RegularExpressions;
+ use WWW::StrawViewer::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,2}+)(?>-|\.\.)([0-9]{1,2}+)?\z};
+our $digit_or_equal_re = qr/(?(?=[1-9])|=)/;
+our $non_digit_or_opt_re = qr{^(?!$range_num_re)(?>[0-9]{1,2}[^0-9]|[0-9]{3}|[^0-9$opt_begin_chars])};
+
+# Generic name
+my $generic_name_re = qr/[a-zA-Z0-9_.\-]{11,34}/;
+our $valid_channel_id_re = qr{^(?:.*/channel/)?(?<channel_id>(?:\w+(?:[-.]++\w++)*|$generic_name_re))(?:/.*)?\z};
+
+our $get_channel_videos_id_re = qr{^.*/channel/(?<channel_id>(?:\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/(?<username>[-.\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>$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/)(?<playlist_id>$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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::RegularExpressions
diff --git a/lib/WWW/StrawViewer/Search.pm b/lib/WWW/StrawViewer/Search.pm
new file mode 100644
index 0000000..67b8982
--- /dev/null
+++ b/lib/WWW/StrawViewer/Search.pm
@@ -0,0 +1,175 @@
+package WWW::StrawViewer::Search;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::Search - Search functions for Straw API v3
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->new(%opts);
+ $obj->search_videos(@keywords);
+
+=head1 SUBROUTINES/METHODS
+
+=cut
+
+sub _make_search_url {
+ my ($self, %opts) = @_;
+
+ return $self->_make_feed_url(
+ 'search',
+
+ topicId => $self->get_topicId,
+ regionCode => $self->get_regionCode,
+
+ maxResults => $self->get_maxResults,
+ order => $self->get_order,
+ publishedAfter => $self->get_publishedAfter,
+ publishedBefore => $self->get_publishedBefore,
+ regionCode => $self->get_regionCode,
+ relevanceLanguage => $self->get_relevanceLanguage,
+ safeSearch => $self->get_safeSearch,
+ channelId => $self->get_channelId,
+ channelType => $self->get_channelType,
+ pageToken => $self->page_token,
+
+ (
+ $opts{type} eq 'video'
+ ? (
+ videoCaption => $self->get_videoCaption,
+ videoCategoryId => $self->get_videoCategoryId,
+ videoDefinition => $self->get_videoDefinition,
+ videoDimension => $self->get_videoDimension,
+ videoDuration => $self->get_videoDuration,
+ videoEmbeddable => $self->get_videoEmbeddable,
+ videoLicense => $self->get_videoLicense,
+ videoSyndicated => $self->get_videoSyndicated,
+ videoType => $self->get_videoType,
+ eventType => $self->get_eventType,
+ )
+ : ()
+ ),
+
+ %opts,
+ );
+
+}
+
+=head2 search_for($types,$keywords;\%args)
+
+Search for a list of types (comma-separated).
+
+=cut
+
+sub search_for {
+ my ($self, $type, $keywords, $args) = @_;
+
+ $keywords //= [];
+ if (ref $keywords ne 'ARRAY') {
+ $keywords = [split ' ', $keywords];
+ }
+
+ my $url = $self->_make_search_url(
+ type => $type,
+ q => $self->escape_string(join(' ', @{$keywords})),
+ (ref $args eq 'HASH' ? %{$args} : (part => 'snippet')),
+ );
+
+ 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 => 'video,channel,playlist',
+ }
+ ) {
+ *{__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, $id) = @_;
+ return $self->search_for('video', [], {relatedToVideoId => $id});
+}
+
+=head1 AUTHOR
+
+Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::Search
diff --git a/lib/WWW/StrawViewer/Subscriptions.pm b/lib/WWW/StrawViewer/Subscriptions.pm
new file mode 100644
index 0000000..063a875
--- /dev/null
+++ b/lib/WWW/StrawViewer/Subscriptions.pm
@@ -0,0 +1,272 @@
+package WWW::StrawViewer::Subscriptions;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::Subscriptions - Subscriptions handler.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::Subscriptions
diff --git a/lib/WWW/StrawViewer/Utils.pm b/lib/WWW/StrawViewer/Utils.pm
new file mode 100644
index 0000000..f3855b8
--- /dev/null
+++ b/lib/WWW/StrawViewer/Utils.pm
@@ -0,0 +1,735 @@
+package WWW::StrawViewer::Utils;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::Utils - Various utils.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer::Utils;
+
+ my $yv_utils = WWW::StrawViewer::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<format_date()>
+
+=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 =~ /\bwebm\b/i ? q{webm}
+ : $type =~ /\b3gpp?\b/i ? q{3gp}
+ : $type =~ m{^video/(\w+)} ? $1
+ : $type =~ m{^audio/(\w+)} ? $1
+ : 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{^
+ (?<year>\d{4})
+ -
+ (?<month>\d{2})
+ -
+ (?<day>\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{^
+ (?<year>\d{4})
+ -
+ (?<month>\d{2})
+ -
+ (?<day>\d{2})
+ [a-zA-Z]
+ (?<hour>\d{2})
+ :
+ (?<min>\d{2})
+ :
+ (?<sec>\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) = @_;
+ scalar(@{$result->{results}}) > 0;
+ #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};
+}
+
+=head2 get_description($info)
+
+Get description.
+
+=cut
+
+sub get_description {
+ my ($self, $info) = @_;
+ my $desc = $info->{description};
+ (defined($desc) and $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) = @_;
+ my @thumbs = @{$info->{videoThumbnails}};
+ my @wanted = grep{$_->{quality} eq $type} @thumbs;
+
+ if (@wanted) {
+ return $wanted[0]{url};
+ }
+
+ warn "[!] Couldn't find thumbnail of type <<$type>>...";
+ $thumbs[0]{url};
+}
+
+sub get_channel_title {
+ my ($self, $info) = @_;
+ #$info->{snippet}{channelTitle} || $self->get_channel_id($info);
+ $info->{author};
+}
+
+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";
+}
+
+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';
+}
+
+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+) 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->{statistics}{likeCount};
+}
+
+sub get_dislikes {
+ my ($self, $info) = @_;
+ $info->{statistics}{dislikeCount};
+}
+
+sub get_comments {
+ my ($self, $info) = @_;
+ $info->{statistics}{commentCount};
+}
+
+{
+ 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) = @_;
+
+ 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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::Utils
diff --git a/lib/WWW/StrawViewer/VideoCategories.pm b/lib/WWW/StrawViewer/VideoCategories.pm
new file mode 100644
index 0000000..30e2e6e
--- /dev/null
+++ b/lib/WWW/StrawViewer/VideoCategories.pm
@@ -0,0 +1,97 @@
+package WWW::StrawViewer::VideoCategories;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::VideoCategories - videoCategory resource handler.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->new(%opts);
+ my $cats = $obj->video_categories();
+
+=head1 SUBROUTINES/METHODS
+
+=cut
+
+sub _make_videoCategories_url {
+ my ($self, %opts) = @_;
+
+ $self->_make_feed_url(
+ 'videoCategories',
+ hl => $self->get_hl,
+ %opts,
+ );
+}
+
+=head2 video_categories()
+
+Return video categories for a specific region ID.
+
+=cut
+
+sub video_categories {
+ my ($self) = @_;
+
+ require File::Spec;
+
+ my $region = $self->get_regionCode() // 'US';
+ my $url = $self->_make_videoCategories_url(regionCode => $region);
+ my $file = File::Spec->catfile($self->get_config_dir, "categories-$region-" . $self->get_hl() . ".json");
+
+ my $json;
+ if (open(my $fh, '<:utf8', $file)) {
+ local $/;
+ $json = <$fh>;
+ close $fh;
+ }
+ else {
+ $json = $self->lwp_get($url, simple => 1);
+ open my $fh, '>:utf8', $file;
+ print {$fh} $json;
+ close $fh;
+ }
+
+ return $self->parse_json_string($json);
+}
+
+=head2 video_category_id_info($cagegory_id)
+
+Return info for the comma-separated specified category ID(s).
+
+=cut
+
+sub video_category_id_info {
+ my ($self, $id) = @_;
+ return $self->_get_results($self->_make_videoCategories_url(id => $id));
+}
+
+=head1 AUTHOR
+
+Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::VideoCategories
diff --git a/lib/WWW/StrawViewer/Videos.pm b/lib/WWW/StrawViewer/Videos.pm
new file mode 100644
index 0000000..9df9ff3
--- /dev/null
+++ b/lib/WWW/StrawViewer/Videos.pm
@@ -0,0 +1,229 @@
+package WWW::StrawViewer::Videos;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::StrawViewer::Videos - videos handler.
+
+=head1 SYNOPSIS
+
+ use WWW::StrawViewer;
+ my $obj = WWW::StrawViewer->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 videos_from_category($category_id)
+
+Get videos from a category ID.
+
+=cut
+
+sub videos_from_category {
+ my ($self, $cat_id) = @_;
+ $self->_get_results(
+ $self->_make_videos_url(
+ chart => $self->get_chart,
+ videoCategoryId => $cat_id,
+ )
+ );
+}
+
+=head2 trending_videos_from_category($category_id)
+
+Get popular videos from a category ID.
+
+=cut
+
+sub trending_videos_from_category {
+ my ($self, $cat_id) = @_;
+
+ my $results = do {
+ local $self->{publishedAfter} = do {
+ state $yv_utils = WWW::StrawViewer::Utils->new;
+ $yv_utils->period_to_date(1, 'w');
+ } if !defined($self->get_publishedAfter);
+ local $self->{videoCategoryId} = $cat_id;
+ local $self->{regionCode} = "US" if !defined($self->get_regionCode);
+ $self->search_videos("");
+ };
+
+ return $results;
+}
+
+=head2 popular_videos($channel_id)
+
+Get the most popular videos for a given channel ID.
+
+=cut
+
+sub popular_videos {
+ my ($self, $id) = @_;
+
+ my $results = do {
+ local $self->{channelId} = $id;
+ local $self->{order} = 'viewCount';
+ $self->search_videos("");
+ };
+
+ return $results;
+}
+
+=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<part> are: I<id>, I<snippet>, I<contentDetails>
+I<player>, I<statistics>, I<status> and I<topicDetails>.
+
+C<$part> string can contain more values, comma-separated.
+
+Example:
+
+ part => 'snippet,contentDetails,statistics'
+
+When C<$part> is C<undef>, it defaults to I<snippet>.
+
+=cut
+
+sub video_details {
+ my ($self, $id, $part) = @_;
+ return $self->_get_results($self->_make_videos_url(id => $id, part => $part // 'snippet'));
+}
+
+=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<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::StrawViewer::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<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::StrawViewer::Videos
diff --git a/share/gtk-straw-viewer.desktop b/share/gtk-straw-viewer.desktop
new file mode 100644
index 0000000..10d73d0
--- /dev/null
+++ b/share/gtk-straw-viewer.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Name=GTK Youtube Viewer
+Version=1.0
+Comment=Search and play YouTube videos.
+Exec=gtk-straw-viewer
+Icon=gtk-straw-viewer
+StartupNotify=false
+Terminal=false
+Type=Application
+Categories=AudioVideo;GTK;
diff --git a/share/gtk3-straw-viewer.glade b/share/gtk3-straw-viewer.glade
new file mode 100644
index 0000000..8d6cc4a
--- /dev/null
+++ b/share/gtk3-straw-viewer.glade
@@ -0,0 +1,3606 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.22.1
+
+Copyright (C) Copyright © 2010-2020 Trizen
+
+This file is part of GTK Straw Viewer.
+
+GTK Straw Viewer is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+GTK Straw Viewer 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 the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with GTK Straw Viewer. If not, see <http://www.gnu.org/licenses/>.
+
+Author: Trizen https://github.com/trizen
+
+-->
+<interface>
+ <requires lib="gtk+" version="3.20"/>
+ <!-- interface-license-type gplv3 -->
+ <!-- interface-name GTK Straw Viewer -->
+ <!-- interface-description Search and play YouTube videos. -->
+ <!-- interface-copyright Copyright \302\251 2010-2020 Trizen -->
+ <!-- interface-authors Trizen https://github.com/trizen -->
+ <object class="GtkAdjustment" id="adjustment1">
+ <property name="lower">1</property>
+ <property name="upper">50</property>
+ <property name="step_increment">1</property>
+ <property name="page_increment">10</property>
+ </object>
+ <object class="GtkAdjustment" id="adjustment2">
+ <property name="lower">1</property>
+ <property name="upper">4096</property>
+ <property name="value">1</property>
+ <property name="step_increment">1</property>
+ <property name="page_increment">10</property>
+ </object>
+ <object class="GtkAdjustment" id="adjustment3">
+ <property name="lower">1</property>
+ <property name="upper">1000</property>
+ <property name="value">1</property>
+ <property name="step_increment">1</property>
+ <property name="page_increment">10</property>
+ </object>
+ <object class="GtkImage" id="download_icon3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">emblem-downloads</property>
+ </object>
+ <object class="GtkEntryBuffer" id="entrybuffer1">
+ <property name="text" translatable="yes">Search for YouTube videos...</property>
+ <signal name="deleted-text" handler="analyze_text" swapped="no"/>
+ <signal name="inserted-text" handler="analyze_text" swapped="no"/>
+ </object>
+ <object class="GtkImage" id="icon-next">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="stock">gtk-go-forward</property>
+ </object>
+ <object class="GtkImage" id="icon-previous">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="stock">gtk-go-back</property>
+ </object>
+ <object class="GtkImage" id="icon_from_pixbuf">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="stock">gtk-missing-image</property>
+ </object>
+ <object class="GtkImage" id="image14">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">emblem-documents</property>
+ </object>
+ <object class="GtkImage" id="image17">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">applications-multimedia</property>
+ </object>
+ <object class="GtkMenu" id="user_option_menu">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkImageMenuItem" id="menuitem8">
+ <property name="label" translatable="yes">Videos</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="image">image17</property>
+ <property name="use_stock">False</property>
+ <signal name="activate" handler="videos_from_selected_username" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="menuitem7">
+ <property name="label" translatable="yes">Playlists</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="image">image14</property>
+ <property name="use_stock">False</property>
+ <signal name="activate" handler="playlists_from_selected_username" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="menuitem5">
+ <property name="label">gtk-remove</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">Remove the selected user from list...</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ <signal name="activate" handler="remove_selected_user" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ <object class="GtkImage" id="image5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">emblem-documents</property>
+ </object>
+ <object class="GtkImage" id="image51">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">emblem-favorite</property>
+ </object>
+ <object class="GtkImage" id="image52">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">application-exit</property>
+ </object>
+ <object class="GtkImage" id="image53">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ <object class="GtkImage" id="image54">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">go-down</property>
+ </object>
+ <object class="GtkImage" id="image59">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">go-up</property>
+ </object>
+ <object class="GtkImage" id="image6">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">dialog-password</property>
+ </object>
+ <object class="GtkImage" id="image7">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="stock">gtk-refresh</property>
+ </object>
+ <object class="GtkImage" id="image76">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">emblem-important</property>
+ </object>
+ <object class="GtkImage" id="image79">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">applications-internet</property>
+ </object>
+ <object class="GtkImage" id="image8">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">mail-reply-all</property>
+ </object>
+ <object class="GtkListStore" id="liststore1">
+ <columns>
+ <!-- column-name name -->
+ <column type="gchararray"/>
+ <!-- column-name pic -->
+ <column type="GdkPixbuf"/>
+ <!-- column-name lenght -->
+ <column type="gchararray"/>
+ <!-- column-name id -->
+ <column type="gchararray"/>
+ <!-- column-name description -->
+ <column type="gchararray"/>
+ <!-- column-name next_page_token -->
+ <column type="gchararray"/>
+ <!-- column-name channel_id -->
+ <column type="gchararray"/>
+ <!-- column-name result_type -->
+ <column type="gchararray"/>
+ <!-- column-name json_data -->
+ <column type="gchararray"/>
+ <!-- column-name tooltip -->
+ <column type="gchararray"/>
+ </columns>
+ </object>
+ <object class="GtkListStore" id="liststore11">
+ <columns>
+ <!-- column-name comment -->
+ <column type="gchararray"/>
+ <!-- column-name url -->
+ <column type="gchararray"/>
+ <!-- column-name next_page_token -->
+ <column type="gchararray"/>
+ <!-- column-name video_id -->
+ <column type="gchararray"/>
+ <!-- column-name comment_id -->
+ <column type="gchararray"/>
+ </columns>
+ </object>
+ <object class="GtkListStore" id="liststore2">
+ <columns>
+ <!-- column-name channel_id -->
+ <column type="gchararray"/>
+ <!-- column-name channel_name -->
+ <column type="gchararray"/>
+ <!-- column-name type -->
+ <column type="gchararray"/>
+ <!-- column-name logo -->
+ <column type="GdkPixbuf"/>
+ </columns>
+ </object>
+ <object class="GtkListStore" id="liststore4">
+ <columns>
+ <!-- column-name name -->
+ <column type="gchararray"/>
+ <!-- column-name url -->
+ <column type="gchararray"/>
+ <!-- column-name icon -->
+ <column type="GdkPixbuf"/>
+ <!-- column-name type -->
+ <column type="gchararray"/>
+ </columns>
+ </object>
+ <object class="GtkListStore" id="liststore6">
+ <columns>
+ <!-- column-name top_name -->
+ <column type="gchararray"/>
+ <!-- column-name feed_icon -->
+ <column type="GdkPixbuf"/>
+ <!-- column-name feed_url -->
+ <column type="gchararray"/>
+ <!-- column-name top_type -->
+ <column type="gchararray"/>
+ </columns>
+ </object>
+ <object class="GtkImage" id="terminal_icon2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">video-display</property>
+ </object>
+ <object class="GtkWindow" id="__MAIN__">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <signal name="delete-event" handler="on_mainw_destroy" swapped="no"/>
+ <signal name="window-state-event" handler="main_window_state_events" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkMenuBar" id="menubar1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkMenuItem" id="file">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Menu</property>
+ <child type="submenu">
+ <object class="GtkMenu" id="menu1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkImageMenuItem" id="username_list">
+ <property name="label" translatable="yes">Saved channels</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">See your list of saved channels</property>
+ <property name="use_stock">False</property>
+ <signal name="activate" handler="show_users_list_window" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="cli_version">
+ <property name="label" translatable="yes">CLI Straw Viewer</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">Search and play videos in command line interface (CTRL+Y)</property>
+ <property name="image">terminal_icon2</property>
+ <property name="use_stock">False</property>
+ <signal name="activate" handler="run_cli_youtube_viewer" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="menuitem3">
+ <property name="label" translatable="yes">Login to YouTube</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">Login to YouTube using OAuth 2.0 authentication.</property>
+ <property name="image">image6</property>
+ <property name="use_stock">False</property>
+ <signal name="activate" handler="show_login_to_youtube_window" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="warnings_console">
+ <property name="label" translatable="yes">Warnings console</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">Show the warnings window</property>
+ <property name="image">image76</property>
+ <property name="use_stock">False</property>
+ <signal name="activate" handler="show_warnings_window" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="options">
+ <property name="label">gtk-preferences</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">Adjust application settings. (CTRL+P)</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ <signal name="activate" handler="show_preferences_window" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkSeparatorMenuItem" id="menuitem6">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="exit">
+ <property name="label">gtk-quit</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">Quit the application. (CTRL+Q)</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ <signal name="activate" handler="on_mainw_destroy" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkMenuItem" id="main-menu-view">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">View</property>
+ <child type="submenu">
+ <object class="GtkMenu" id="main-menu-view-menu">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkImageMenuItem" id="show_prev_results">
+ <property name="label" translatable="yes">Previous results</property>
+ <property name="visible">True</property>
+ <property name="sensitive">False</property>
+ <property name="can_focus">False</property>
+ <property name="image">icon-previous</property>
+ <property name="use_stock">False</property>
+ <signal name="activate" handler="display_previous_results" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="show_next_results">
+ <property name="label" translatable="yes">Next results</property>
+ <property name="visible">True</property>
+ <property name="sensitive">False</property>
+ <property name="can_focus">False</property>
+ <property name="image">icon-next</property>
+ <property name="use_stock">False</property>
+ <signal name="activate" handler="display_next_results" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkMenuItem" id="main-menu-history">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">History</property>
+ <child type="submenu">
+ <object class="GtkMenu" id="main-menu-history-menu">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkMenuItem" id="menuitem4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Help</property>
+ <property name="use_underline">True</property>
+ <child type="submenu">
+ <object class="GtkMenu" id="menuitem4_menu">
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkImageMenuItem" id="help">
+ <property name="label">gtk-help</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">Show a help window. (CTRL+H)</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ <signal name="activate" handler="show_help_window" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="donate_item">
+ <property name="label" translatable="yes">Donate</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">Thank the author, by making a small donation.</property>
+ <property name="image">image79</property>
+ <property name="use_stock">False</property>
+ <signal name="activate" handler="donate" swapped="no"/>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="about1">
+ <property name="label">gtk-about</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ <signal name="activate" handler="show_about_window" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkPaned" id="hbox2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">never</property>
+ <child>
+ <object class="GtkTreeView" id="treeview2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="model">liststore1</property>
+ <property name="reorderable">True</property>
+ <property name="search_column">1</property>
+ <property name="tooltip_column">9</property>
+ <signal name="row-activated" handler="get_code" swapped="no"/>
+ <child internal-child="selection">
+ <object class="GtkTreeSelection"/>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn2">
+ <property name="visible">False</property>
+ <property name="resizable">True</property>
+ <property name="title" translatable="yes">Thumbnails</property>
+ <property name="clickable">True</property>
+ <property name="reorderable">True</property>
+ <child>
+ <object class="GtkCellRendererPixbuf" id="cellrendererpixbuf1"/>
+ <attributes>
+ <attribute name="pixbuf">1</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn1">
+ <property name="resizable">True</property>
+ <property name="sizing">fixed</property>
+ <property name="fixed_width">100</property>
+ <property name="title">Title</property>
+ <property name="expand">True</property>
+ <property name="clickable">True</property>
+ <property name="reorderable">True</property>
+ <child>
+ <object class="GtkCellRendererText" id="cellrenderertext1"/>
+ <attributes>
+ <attribute name="markup">0</attribute>
+ <attribute name="text">3</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn3">
+ <property name="resizable">True</property>
+ <property name="title">Info</property>
+ <property name="clickable">True</property>
+ <property name="reorderable">True</property>
+ <child>
+ <object class="GtkCellRendererText" id="cellrenderertext2"/>
+ <attributes>
+ <attribute name="markup">2</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="resize">True</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkBox" id="hbox1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkEntry" id="search_entry">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="has_focus">True</property>
+ <property name="is_focus">True</property>
+ <property name="buffer">entrybuffer1</property>
+ <property name="invisible_char">•</property>
+ <property name="activates_default">True</property>
+ <property name="caps_lock_warning">False</property>
+ <property name="primary_icon_stock">gtk-find</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="search" swapped="no"/>
+ <signal name="button-press-event" handler="clear_text" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkImage" id="gif_spinner">
+ <property name="can_focus">False</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">never</property>
+ <property name="vscrollbar_policy">never</property>
+ <child>
+ <object class="GtkViewport" id="viewport1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkBox" id="vbox3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkNotebook" id="notebook1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="scrollable">True</property>
+ <child>
+ <object class="GtkScrolledWindow" id="subsc_scrollwindow">
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">never</property>
+ <child>
+ <object class="GtkViewport" id="viewport2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkBox" id="vbox23">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkButtonBox" id="vbuttonbox5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkButton" id="button6">
+ <property name="label">Subscriptions</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">image53</property>
+ <property name="image_position">top</property>
+ <signal name="clicked" handler="subscriptions_button" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button22">
+ <property name="label" translatable="yes">Favorited videos</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">image51</property>
+ <property name="image_position">top</property>
+ <signal name="clicked" handler="favorites_button" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button25">
+ <property name="label" translatable="yes">Liked videos</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">image59</property>
+ <property name="image_position">top</property>
+ <signal name="clicked" handler="likes_button" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button9">
+ <property name="label" translatable="yes">Disliked videos</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">image54</property>
+ <property name="image_position">top</property>
+ <signal name="clicked" handler="dislikes_button" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="uploads_button">
+ <property name="label" translatable="yes">Uploads</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">icon_from_pixbuf</property>
+ <property name="image_position">top</property>
+ <signal name="clicked" handler="uploads_button" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button11">
+ <property name="label" translatable="yes">Activity</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">image7</property>
+ <property name="image_position">top</property>
+ <signal name="clicked" handler="activity_button" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">5</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button10">
+ <property name="label" translatable="yes">Playlists</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">image5</property>
+ <property name="image_position">top</property>
+ <signal name="clicked" handler="playlists_button" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">6</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button7">
+ <property name="label" translatable="yes">Log out</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">image52</property>
+ <property name="image_position">top</property>
+ <signal name="clicked" handler="log_out" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">7</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="padding">50</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame12">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment28">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext13">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <items>
+ <item translatable="yes">relevance</item>
+ <item translatable="yes">unread</item>
+ <item translatable="yes">alphabetical</item>
+ </items>
+ <signal name="changed" handler="combobox_subscriptions_order_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label38">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Subscriptions order:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame7">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment7">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="xalign">0</property>
+ <property name="yalign">0</property>
+ <property name="left_padding">9</property>
+ <child>
+ <object class="GtkBox" id="hbox9">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkEntry" id="panel_user_entry">
+ <property name="can_focus">True</property>
+ <property name="invisible_char">•</property>
+ <property name="width_chars">15</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext6">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="active">0</property>
+ <items>
+ <item translatable="yes">myself</item>
+ <item translatable="yes">username</item>
+ <item translatable="yes">channel ID</item>
+ </items>
+ <signal name="changed" handler="combobox_panel_account_changed" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label14">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Account:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="tab">
+ <object class="GtkLabel" id="subsc_label">
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">My panel</property>
+ </object>
+ <packing>
+ <property name="tab_fill">False</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow13">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkViewport" id="viewport5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkBox" id="vbox5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkExpander" id="expander2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkAlignment" id="alignment11">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkBox" id="vbox22">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkCheckButton" id="channels_checkbox">
+ <property name="label" translatable="yes">Channels</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="playlists_checkbox">
+ <property name="label" translatable="yes">Playlists</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="videos_checkbox">
+ <property name="label" translatable="yes">Videos</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label6">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Search for:</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame8">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment8">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkBox" id="vbox11">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="active">0</property>
+ <items>
+ <item translatable="yes">relevance</item>
+ <item translatable="yes">title</item>
+ <item translatable="yes">rating</item>
+ <item translatable="yes">date</item>
+ <item translatable="yes">videoCount</item>
+ <item translatable="yes">viewCount</item>
+ </items>
+ <signal name="changed" handler="combobox_order_changed" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label16">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Order by:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame18">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment17">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext8">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">short – less than 4 minutes long
+medium – 4 to 20 minutes (inclusive)
+long – longer than 20 minutes</property>
+ <property name="active">0</property>
+ <items>
+ <item translatable="yes">any</item>
+ <item translatable="yes">short</item>
+ <item translatable="yes">medium</item>
+ <item translatable="yes">long</item>
+ </items>
+ <signal name="changed" handler="combobox_duration_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label20">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Duration:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame19">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment18">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="active">0</property>
+ <items>
+ <item translatable="yes">any</item>
+ <item translatable="yes">closedCaption</item>
+ <item translatable="yes">none</item>
+ </items>
+ <signal name="changed" handler="combobox_caption_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label21">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Video Caption:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame20">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment19">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="active">0</property>
+ <items>
+ <item translatable="yes">any</item>
+ <item translatable="yes">high</item>
+ <item translatable="yes">standard</item>
+ </items>
+ <signal name="changed" handler="combobox_definition_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label25">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Video Definition:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame21">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment20">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="active">1</property>
+ <items>
+ <item translatable="yes">none</item>
+ <item translatable="yes">moderate</item>
+ <item translatable="yes">strict</item>
+ </items>
+ <signal name="changed" handler="combobox_safesearch_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label26">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Safe Search:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">5</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkExpander" id="more_options_expander">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <signal name="activate" handler="activate_more_options_expander" swapped="no"/>
+ <child>
+ <object class="GtkBox" id="vbox20">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkFrame" id="frame11">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment12">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkSpinButton" id="spinbutton1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="tooltip_text" translatable="yes">The maximum number of results per page.
+Recommended: 10</property>
+ <property name="max_length">2</property>
+ <property name="invisible_char">•</property>
+ <property name="caps_lock_warning">False</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <property name="adjustment">adjustment1</property>
+ <property name="climb_rate">1</property>
+ <property name="numeric">True</property>
+ <property name="update_policy">if-valid</property>
+ <signal name="activate" handler="search" swapped="no"/>
+ <signal name="value-changed" handler="spin_results_per_page_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label19">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Results per page:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame9">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment9">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkSpinButton" id="spinbutton2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="tooltip_text" translatable="yes">List videos, starting with a specific page.</property>
+ <property name="invisible_char">•</property>
+ <property name="caps_lock_warning">False</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <property name="adjustment">adjustment2</property>
+ <property name="climb_rate">1</property>
+ <property name="numeric">True</property>
+ <property name="update_policy">if-valid</property>
+ <signal name="activate" handler="search" swapped="no"/>
+ <signal name="value-changed" handler="spin_start_with_page_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label7">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Start with page:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame27">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment25">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkBox" id="hbox8">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkSpinButton" id="spinbutton3">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="invisible_char">•</property>
+ <property name="caps_lock_warning">False</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <property name="adjustment">adjustment3</property>
+ <property name="climb_rate">1</property>
+ <property name="numeric">True</property>
+ <property name="update_policy">if-valid</property>
+ <signal name="activate" handler="search" swapped="no"/>
+ <signal name="value-changed" handler="spin_published_within_changed" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <items>
+ <item translatable="yes">anytime</item>
+ <item translatable="yes">days</item>
+ <item translatable="yes">weeks</item>
+ <item translatable="yes">months</item>
+ <item translatable="yes">years</item>
+ </items>
+ <signal name="changed" handler="combobox_published_within_changed" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label35">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Published within:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame13">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment13">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="from_author_entry">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="tooltip_text" translatable="yes">Search in videos uploaded by a specific author.
+Unless the author name is valid, this field is ignored.</property>
+ <property name="invisible_char">•</property>
+ <property name="text" translatable="yes">Insert a valid author name...</property>
+ <property name="caps_lock_warning">False</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="search" swapped="no"/>
+ <signal name="button-press-event" handler="clear_text" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label17">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;From author:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame25">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment24">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="category_id_entry">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="tooltip_text" translatable="yes">Search in videos uploaded in a specific category.
+Unless the categoryID is valid, this field is ignored.</property>
+ <property name="invisible_char">•</property>
+ <property name="text" translatable="yes">Insert a valid category ID...</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="search" swapped="no"/>
+ <signal name="button-press-event" handler="clear_text" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label33">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;CategoryID:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkBox" id="vbox10">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkCheckButton" id="thumbs_checkbutton">
+ <property name="label" translatable="yes">Show thumbnails</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <signal name="toggled" handler="thumbs_checkbutton_toggled" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="fullscreen_checkbutton">
+ <property name="label" translatable="yes">Fullscreen mode</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <signal name="toggled" handler="toggled_fullscreen" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="dash_checkbutton">
+ <property name="label" translatable="yes">DASH support</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <signal name="toggled" handler="toggled_dash_support" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="audio_only_checkbutton">
+ <property name="label" translatable="yes">Audio only</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <signal name="toggled" handler="toggled_audio_only" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="clear_list_checkbutton">
+ <property name="label" translatable="yes">Clear search list</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <signal name="toggled" handler="toggled_clear_search_list" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Other options:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">5</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label32">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">More options</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">6</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButtonBox" id="hbuttonbox6">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="homogeneous">True</property>
+ <property name="layout_style">center</property>
+ <child>
+ <object class="GtkButton" id="button8">
+ <property name="label">gtk-find</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="search" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="padding">14</property>
+ <property name="position">7</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame10">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment10">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext9">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="tooltip_text" translatable="yes">Video resolution (default: best)
+When the specified resolution is not found, the best available resolution is used.</property>
+ <property name="active">0</property>
+ <items>
+ <item translatable="yes">best</item>
+ <item translatable="yes">2160p</item>
+ <item translatable="yes">1440p</item>
+ <item translatable="yes">1080p</item>
+ <item translatable="yes">720p</item>
+ <item translatable="yes">480p</item>
+ <item translatable="yes">360p</item>
+ <item translatable="yes">240p</item>
+ </items>
+ <signal name="changed" handler="combobox_resolution_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label18">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Resolution&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">8</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="menu_label">Search options</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child type="tab">
+ <object class="GtkLabel" id="label1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Settings</property>
+ <property name="track_visited_links">False</property>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ <property name="tab_fill">False</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox8">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkFrame" id="frame4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="entry1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="tooltip_text" translatable="yes">Insert an YouTube username</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="uploads_from_text_entry" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label9">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Uploads:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="padding">3</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame24">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment23">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="entry3">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="tooltip_text" translatable="yes">Insert an YouTube username</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="favorites_from_text_entry" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label30">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Favorites:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="padding">3</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame26">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="entry4">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="subscriptions_from_text_entry" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label34">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Subscriptions:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame29">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment27">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="entry5">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="likes_from_text_entry" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label37">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Likes:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="entry2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="tooltip_text" translatable="yes">Insert an YouTube username</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="playlists_from_text_entry" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Playlists:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="padding">3</property>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame28">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="label_yalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment26">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkComboBoxText" id="comboboxtext7">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="active">0</property>
+ <items>
+ <item translatable="yes">username</item>
+ <item translatable="yes">channel ID</item>
+ </items>
+ <signal name="changed" handler="combobox_channel_type_changed" swapped="no"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label36">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Channel type:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">5</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child type="tab">
+ <object class="GtkLabel" id="label2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Channel</property>
+ </object>
+ <packing>
+ <property name="position">2</property>
+ <property name="tab_fill">False</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow8">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkTreeView" id="treeview3">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="model">liststore4</property>
+ <property name="search_column">0</property>
+ <signal name="row-activated" handler="list_category" swapped="no"/>
+ <child internal-child="selection">
+ <object class="GtkTreeSelection"/>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn6">
+ <property name="resizable">True</property>
+ <property name="title" translatable="yes">Icon</property>
+ <property name="clickable">True</property>
+ <property name="reorderable">True</property>
+ <child>
+ <object class="GtkCellRendererPixbuf" id="cellrendererpixbuf"/>
+ <attributes>
+ <attribute name="pixbuf">2</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn">
+ <property name="resizable">True</property>
+ <property name="sizing">fixed</property>
+ <property name="title">Category</property>
+ <property name="expand">True</property>
+ <property name="clickable">True</property>
+ <property name="reorderable">True</property>
+ <child>
+ <object class="GtkCellRendererText" id="cellrenderertext"/>
+ <attributes>
+ <attribute name="markup">0</attribute>
+ <attribute name="text">1</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child type="tab">
+ <object class="GtkLabel" id="label3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Categories</property>
+ </object>
+ <packing>
+ <property name="position">3</property>
+ <property name="tab_fill">False</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow7">
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkViewport" id="viewport4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkBox" id="vbox12">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkTreeView" id="treeview4">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="model">liststore6</property>
+ <signal name="row-activated" handler="list_tops" swapped="no"/>
+ <child internal-child="selection">
+ <object class="GtkTreeSelection"/>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn4">
+ <property name="title" translatable="yes">Icon</property>
+ <child>
+ <object class="GtkCellRendererPixbuf" id="cellrendererpixbuf2"/>
+ <attributes>
+ <attribute name="pixbuf">1</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn5">
+ <property name="title" translatable="yes">Top</property>
+ <child>
+ <object class="GtkCellRendererText" id="cellrenderertext3"/>
+ <attributes>
+ <attribute name="markup">0</attribute>
+ <attribute name="text">2</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkExpander" id="expander1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkBox" id="vbox19">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkFrame" id="frame22">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment21">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="region_entry">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label28">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Region ID&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame23">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment22">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="category_entry">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label29">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Category ID:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label27">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Options</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ <child type="tab">
+ <object class="GtkLabel" id="label15">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Tops</property>
+ </object>
+ <packing>
+ <property name="position">4</property>
+ <property name="tab_fill">False</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkStatusbar" id="statusbar1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">2</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="resize">True</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkAboutDialog" id="aboutdialog1">
+ <property name="can_focus">False</property>
+ <property name="border_width">5</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="type_hint">normal</property>
+ <property name="transient_for">__MAIN__</property>
+ <property name="program_name">GTK Straw Viewer</property>
+ <property name="copyright" translatable="yes">Copyright © 2010-2020 Trizen</property>
+ <property name="comments" translatable="yes">Written in Perl, Gtk3 and Glade.</property>
+ <property name="website">https://github.com/trizen/straw-viewer</property>
+ <property name="website_label" translatable="yes">https://github.com/trizen/straw-viewer</property>
+ <property name="authors">Trizen https://github.com/trizen
+Ovidiu D. Nițan &lt;nitanovidiu@gmail.com&gt;
+Jookia https://github.com/Jookia
+Andreas Hrubak https://github.com/bAndie91
+and others... https://github.com/trizen/straw-viewer/graphs/contributors</property>
+ <property name="artists">PosixRU (main logo) http://zenway.ru/page/gtk-youtube-viewer</property>
+ <property name="logo_icon_name">image-missing</property>
+ <property name="license_type">artistic</property>
+ <signal name="delete-event" handler="hide_about_window" swapped="no"/>
+ <signal name="destroy" handler="hide_about_window" swapped="no"/>
+ <signal name="response" handler="hide_about_window" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child internal-child="vbox">
+ <object class="GtkBox" id="dialog-vbox3">
+ <property name="can_focus">False</property>
+ <child internal-child="action_area">
+ <object class="GtkButtonBox" id="dialog-action_area3">
+ <property name="can_focus">False</property>
+ <property name="layout_style">end</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkWindow" id="details_window">
+ <property name="can_focus">False</property>
+ <property name="title" translatable="yes">Video details</property>
+ <property name="modal">True</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_height">400</property>
+ <property name="destroy_with_parent">True</property>
+ <property name="gravity">static</property>
+ <property name="transient_for">__MAIN__</property>
+ <signal name="delete-event" handler="hide_details_window" swapped="no"/>
+ <signal name="destroy" handler="hide_details_window" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkBox" id="vbox">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkLabel" id="video_title_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Details window label</property>
+ <property name="use_markup">True</property>
+ <property name="justify">fill</property>
+ <property name="selectable">True</property>
+ <property name="ellipsize">end</property>
+ <property name="track_visited_links">False</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkSeparator" id="hseparator3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox13">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkBox" id="vbox18">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkBox" id="vbox24">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkBox" id="hbox16">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkImage" id="image1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkImage" id="image2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkImage" id="image3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkSeparator" id="hseparator2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkPaned" id="vpaned1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkLabel" id="video_details_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="use_markup">True</property>
+ <property name="wrap">True</property>
+ <property name="selectable">True</property>
+ <property name="ellipsize">end</property>
+ </object>
+ <packing>
+ <property name="resize">False</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">out</property>
+ <child>
+ <object class="GtkAlignment" id="alignment3">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow4">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkTextView" id="description_textview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="pixels_below_lines">2</property>
+ <property name="wrap_mode">word</property>
+ <property name="accepts_tab">False</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label8">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Description</property>
+ <attributes>
+ <attribute name="style" value="normal"/>
+ <attribute name="weight" value="bold"/>
+ <attribute name="variant" value="normal"/>
+ <attribute name="stretch" value="ultra-condensed"/>
+ <attribute name="scale" value="1.3"/>
+ </attributes>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="resize">True</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButtonBox" id="hbuttonbox10">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="layout_style">center</property>
+ <child>
+ <object class="GtkLinkButton" id="linkbutton1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="relief">none</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButtonBox" id="hbuttonbox2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkButton" id="play">
+ <property name="label">gtk-media-play</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="has_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="get_code" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="download">
+ <property name="label">Download</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">download_icon3</property>
+ <signal name="clicked" handler="download_video" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="close">
+ <property name="label">gtk-close</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="hide_details_window" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkDialog" id="errors_window">
+ <property name="can_focus">False</property>
+ <property name="border_width">5</property>
+ <property name="title" translatable="yes">Error!</property>
+ <property name="modal">True</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_width">300</property>
+ <property name="default_height">200</property>
+ <property name="type_hint">dialog</property>
+ <property name="transient_for">__MAIN__</property>
+ <signal name="close" handler="hide_errors_window" swapped="no"/>
+ <signal name="delete-event" handler="hide_errors_window" swapped="no"/>
+ <signal name="destroy" handler="hide_errors_window" swapped="no"/>
+ <signal name="response" handler="hide_errors_window" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child internal-child="vbox">
+ <object class="GtkBox" id="dialog-vbox2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">1</property>
+ <child internal-child="action_area">
+ <object class="GtkButtonBox" id="dialog-action_area2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="button3">
+ <property name="label">gtk-ok</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="has_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="hide_errors_window" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="hbox4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkImage" id="image4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">dialog-warning</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label10">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes"> Something went wrong...</property>
+ <attributes>
+ <attribute name="style" value="normal"/>
+ <attribute name="weight" value="bold"/>
+ <attribute name="scale" value="1.3999999999999999"/>
+ </attributes>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow6">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkTextView" id="errors_textview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="accepts_tab">False</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <action-widgets>
+ <action-widget response="0">button3</action-widget>
+ </action-widgets>
+ </object>
+ <object class="GtkWindow" id="feeds_window">
+ <property name="can_focus">False</property>
+ <property name="title" translatable="yes">YouTube comments</property>
+ <property name="modal">True</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_width">450</property>
+ <property name="default_height">400</property>
+ <property name="destroy_with_parent">True</property>
+ <property name="transient_for">__MAIN__</property>
+ <signal name="delete-event" handler="hide_feeds_window" swapped="no"/>
+ <signal name="destroy" handler="hide_feeds_window" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkLabel" id="feeds_title">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Comments window label</property>
+ <property name="use_markup">True</property>
+ <property name="justify">fill</property>
+ <property name="selectable">True</property>
+ <property name="ellipsize">end</property>
+ <property name="track_visited_links">False</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkPaned">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkScrolledWindow">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="shadow_type">in</property>
+ <child>
+ <object class="GtkTreeView" id="feeds_treeview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="model">liststore11</property>
+ <signal name="row-activated" handler="comments_row_activated" swapped="no"/>
+ <child internal-child="selection">
+ <object class="GtkTreeSelection"/>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn7">
+ <property name="title" translatable="yes">Comments</property>
+ <property name="clickable">True</property>
+ <child>
+ <object class="GtkCellRendererText" id="cellrenderertext6"/>
+ <attributes>
+ <attribute name="markup">0</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="resize">True</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkAlignment">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkScrolledWindow">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="shadow_type">in</property>
+ <child>
+ <object class="GtkTextView" id="comment_textview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="has_focus">True</property>
+ <property name="wrap_mode">word</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Write your comment:</property>
+ <attributes>
+ <attribute name="weight" value="bold"/>
+ </attributes>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="resize">False</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButtonBox">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton">
+ <property name="label" translatable="yes">Send</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="image">image8</property>
+ <signal name="clicked" handler="send_comment_to_video" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton">
+ <property name="label">gtk-refresh</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="set_comments" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton">
+ <property name="label">gtk-close</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="hide_feeds_window" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkStatusbar" id="feeds_statusbar">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="margin_left">10</property>
+ <property name="margin_right">10</property>
+ <property name="margin_start">10</property>
+ <property name="margin_end">10</property>
+ <property name="margin_top">6</property>
+ <property name="margin_bottom">6</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkWindow" id="help_window">
+ <property name="can_focus">False</property>
+ <property name="title" translatable="yes">Help</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_width">480</property>
+ <property name="default_height">400</property>
+ <property name="transient_for">__MAIN__</property>
+ <signal name="delete-event" handler="hide_help_window" swapped="no"/>
+ <signal name="destroy" handler="hide_help_window" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox251">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow3">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkTextView" id="textview2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="pixels_below_lines">1</property>
+ <property name="indent">3</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButtonBox" id="hbuttonbox4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">5</property>
+ <property name="homogeneous">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="button19">
+ <property name="label">gtk-about</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="show_about_window" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button28">
+ <property name="label">gtk-close</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="hide_help_window" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="pack_type">end</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkDialog" id="login_to_youtube">
+ <property name="can_focus">False</property>
+ <property name="border_width">5</property>
+ <property name="title" translatable="yes">Login to YouTube</property>
+ <property name="type_hint">dialog</property>
+ <property name="transient_for">__MAIN__</property>
+ <signal name="close" handler="hide_login_to_youtube_window" swapped="no"/>
+ <signal name="delete-event" handler="hide_login_to_youtube_window" swapped="no"/>
+ <signal name="destroy" handler="hide_login_to_youtube_window" swapped="no"/>
+ <signal name="response" handler="hide_login_to_youtube_window" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child internal-child="vbox">
+ <object class="GtkBox" id="dialog-vbox4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">2</property>
+ <child internal-child="action_area">
+ <object class="GtkButtonBox" id="dialog-action_area4">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkButton" id="button5">
+ <property name="label">gtk-ok</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="authenticate" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button4">
+ <property name="label">gtk-cancel</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="hide_login_to_youtube_window" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label11">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Connect to your YouTube account</property>
+ <attributes>
+ <attribute name="weight" value="bold"/>
+ <attribute name="scale" value="2"/>
+ </attributes>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox7">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkFrame" id="frame5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkButtonBox" id="hbuttonbox9">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="layout_style">start</property>
+ <child>
+ <object class="GtkLinkButton" id="get_auth_link_button">
+ <property name="label" translatable="yes">Click here to get the authentication code!</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="relief">half</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label12">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;OAuth 2.0 authentication&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="padding">7</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkFrame" id="frame6">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label_xalign">0</property>
+ <property name="shadow_type">none</property>
+ <child>
+ <object class="GtkAlignment" id="alignment6">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="left_padding">12</property>
+ <child>
+ <object class="GtkEntry" id="auth_token_entry">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label13">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">&lt;b&gt;Authentication code:&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="login_check_button">
+ <property name="label" translatable="yes">Remember me</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="active">True</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <action-widgets>
+ <action-widget response="0">button5</action-widget>
+ <action-widget response="0">button4</action-widget>
+ </action-widgets>
+ </object>
+ <object class="GtkWindow" id="prefernces_window">
+ <property name="can_focus">False</property>
+ <property name="title" translatable="yes">Configuration</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_width">600</property>
+ <property name="default_height">480</property>
+ <property name="transient_for">__MAIN__</property>
+ <signal name="delete-event" handler="hide_preferences_window" swapped="no"/>
+ <signal name="destroy" handler="hide_preferences_window" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox6">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow5">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkTextView" id="textview3">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="pixels_above_lines">2</property>
+ <property name="pixels_below_lines">2</property>
+ <property name="indent">2</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButtonBox" id="hbuttonbox5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">5</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="button1">
+ <property name="label">gtk-apply</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="save_configuration" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button2">
+ <property name="label">gtk-close</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="hide_preferences_window" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="pack_type">end</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkWindow" id="users_list_window">
+ <property name="can_focus">False</property>
+ <property name="title" translatable="yes">Saved channels</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_width">400</property>
+ <property name="default_height">300</property>
+ <property name="transient_for">__MAIN__</property>
+ <signal name="delete-event" handler="hide_users_list_window" swapped="no"/>
+ <signal name="destroy" handler="hide_users_list_window" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox21">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkBox" id="vbox14">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkLabel" id="username_file_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="hbox15">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkLabel" id="label22">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Channel name:</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkEntry" id="channel_name_save">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="save_channel" swapped="no"/>
+ <signal name="button-press-event" handler="clear_text" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="add_user_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Channel ID:</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkEntry" id="channel_id_save">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="invisible_char">•</property>
+ <property name="primary_icon_activatable">False</property>
+ <property name="secondary_icon_activatable">False</property>
+ <signal name="activate" handler="save_channel" swapped="no"/>
+ <signal name="button-press-event" handler="clear_text" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="save_channel">
+ <property name="label">gtk-add</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="save_channel" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">5</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="vbox15">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkSeparator" id="hseparator1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow12">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkTreeView" id="treeview1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="model">liststore2</property>
+ <signal name="row-activated" handler="videos_from_saved_channel" swapped="no"/>
+ <child internal-child="selection">
+ <object class="GtkTreeSelection"/>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn13">
+ <property name="title" translatable="yes">Icon</property>
+ <child>
+ <object class="GtkCellRendererPixbuf" id="cellrendererpixbuf14"/>
+ <attributes>
+ <attribute name="pixbuf">3</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkTreeViewColumn" id="treeviewcolumn23">
+ <property name="title">Channel</property>
+ <child>
+ <object class="GtkCellRendererText" id="cellrenderertext14"/>
+ <attributes>
+ <attribute name="text">1</attribute>
+ </attributes>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButtonBox" id="hbuttonbox3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">5</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="button14">
+ <property name="label">gtk-ok</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="videos_from_saved_channel" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button21">
+ <property name="label">gtk-close</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="hide_users_list_window" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkDialog" id="warnings_window">
+ <property name="can_focus">False</property>
+ <property name="border_width">5</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_width">320</property>
+ <property name="default_height">260</property>
+ <property name="type_hint">dialog</property>
+ <signal name="delete-event" handler="hide_warnings_window" swapped="no"/>
+ <signal name="destroy" handler="hide_warnings_window" swapped="no"/>
+ <child>
+ <placeholder/>
+ </child>
+ <child internal-child="vbox">
+ <object class="GtkBox" id="dialog-vbox5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">1</property>
+ <child internal-child="action_area">
+ <object class="GtkButtonBox" id="dialog-action_area5">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="button26">
+ <property name="label">gtk-close</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="has_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="hide_warnings_window" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="hbox6">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkLabel" id="label31">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Warnings log</property>
+ <attributes>
+ <attribute name="style" value="normal"/>
+ <attribute name="weight" value="bold"/>
+ <attribute name="scale" value="1.3899999999999999"/>
+ </attributes>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkImage" id="image75">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="icon_name">terminal</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow11">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkTextView" id="warnings_textview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <action-widgets>
+ <action-widget response="0">button26</action-widget>
+ </action-widgets>
+ </object>
+</interface>
diff --git a/share/icons/default_thumb.jpg b/share/icons/default_thumb.jpg
new file mode 100644
index 0000000..79135d9
--- /dev/null
+++ b/share/icons/default_thumb.jpg
Binary files differ
diff --git a/share/icons/feed.png b/share/icons/feed.png
new file mode 100644
index 0000000..923b9bd
--- /dev/null
+++ b/share/icons/feed.png
Binary files differ
diff --git a/share/icons/feed_gray.png b/share/icons/feed_gray.png
new file mode 100644
index 0000000..5e8468e
--- /dev/null
+++ b/share/icons/feed_gray.png
Binary files differ
diff --git a/share/icons/logo.png b/share/icons/logo.png
new file mode 100644
index 0000000..656b637
--- /dev/null
+++ b/share/icons/logo.png
Binary files differ
diff --git a/share/icons/spinner.gif b/share/icons/spinner.gif
new file mode 100644
index 0000000..45dc1f6
--- /dev/null
+++ b/share/icons/spinner.gif
Binary files differ
diff --git a/share/icons/user.png b/share/icons/user.png
new file mode 100644
index 0000000..22fa9c2
--- /dev/null
+++ b/share/icons/user.png
Binary files differ
diff --git a/t/00-load.t b/t/00-load.t
new file mode 100644
index 0000000..966e934
--- /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::YoutubeViewer' ) || print "Bail out!\n";
+}
+
+diag( "Testing WWW::YoutubeViewer $WWW::YoutubeViewer::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();