diff options
39 files changed, 16711 insertions, 0 deletions
@@ -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(); @@ -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] @@ -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. + + + +### gtk-straw-viewer + +* GTK+ interface to YouTube. + + + +### 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{&}{&}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/&/&/g; + $text =~ s/</</g; + $text =~ s/>/>/g; + + return $text; +} + +sub decode_entities { + my ($text) = @_; + + return q{} if not defined $text; + + $text =~ s/&/&/g; + $text =~ s/</</g; + $text =~ s/>/>/g; + + return $text; +} + +sub get_code { + my ($code, $iter) = get_selected_entry_code(); + + $code // return; + + Glib::Idle->add( + sub { + my ($code, $iter) = @{$_[0]}; + + my $type = $liststore->get($iter, 7); + + $type eq 'playlist' ? list_playlist($code) + : ($type eq 'channel' || $type eq 'subscription') ? uploads('channel', $code) + : $type eq 'next_page' && $code ne '' ? do { + + my $next_page_token = $liststore->get($iter, 5); + my $results = $yv_obj->next_page($code, $next_page_token); + + if ($yv_utils->has_entries($results)) { + my $label = '<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/^/‎/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"><b>Subscriptions order:</b></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"><b>Account:</b></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"><b>Order by:</b></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"><b>Duration:</b></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"><b>Video Caption:</b></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"><b>Video Definition:</b></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"><b>Safe Search:</b></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"><b>Results per page:</b></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"><b>Start with page:</b></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"><b>Published within:</b></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"><b>From author:</b></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"><b>CategoryID:</b></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"><b>Other options:</b></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"><b>Resolution</b></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"><b>Uploads:</b></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"><b>Favorites:</b></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"><b>Subscriptions:</b></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"><b>Likes:</b></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"><b>Playlists:</b></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"><b>Channel type:</b></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"><b>Region ID</b></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"><b>Category ID:</b></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 <nitanovidiu@gmail.com> +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"><b>OAuth 2.0 authentication</b></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"><b>Authentication code:</b></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 Binary files differnew file mode 100644 index 0000000..79135d9 --- /dev/null +++ b/share/icons/default_thumb.jpg diff --git a/share/icons/feed.png b/share/icons/feed.png Binary files differnew file mode 100644 index 0000000..923b9bd --- /dev/null +++ b/share/icons/feed.png diff --git a/share/icons/feed_gray.png b/share/icons/feed_gray.png Binary files differnew file mode 100644 index 0000000..5e8468e --- /dev/null +++ b/share/icons/feed_gray.png diff --git a/share/icons/logo.png b/share/icons/logo.png Binary files differnew file mode 100644 index 0000000..656b637 --- /dev/null +++ b/share/icons/logo.png diff --git a/share/icons/spinner.gif b/share/icons/spinner.gif Binary files differnew file mode 100644 index 0000000..45dc1f6 --- /dev/null +++ b/share/icons/spinner.gif diff --git a/share/icons/user.png b/share/icons/user.png Binary files differnew file mode 100644 index 0000000..22fa9c2 --- /dev/null +++ b/share/icons/user.png 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 $@; @@ -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(); |