aboutsummaryrefslogtreecommitdiffstats
path: root/lib/WWW/FairViewer
diff options
context:
space:
mode:
Diffstat (limited to 'lib/WWW/FairViewer')
-rw-r--r--lib/WWW/FairViewer/Channels.pm91
-rw-r--r--lib/WWW/FairViewer/CommentThreads.pm1
-rw-r--r--lib/WWW/FairViewer/GetCaption.pm16
-rw-r--r--lib/WWW/FairViewer/GuideCategories.pm1
-rw-r--r--lib/WWW/FairViewer/InitialData.pm1050
-rw-r--r--lib/WWW/FairViewer/Itags.pm207
-rw-r--r--lib/WWW/FairViewer/ParseJSON.pm12
-rw-r--r--lib/WWW/FairViewer/ParseXML.pm3
-rw-r--r--lib/WWW/FairViewer/PlaylistItems.pm8
-rw-r--r--lib/WWW/FairViewer/Playlists.pm12
-rw-r--r--lib/WWW/FairViewer/Search.pm25
-rw-r--r--lib/WWW/FairViewer/Utils.pm351
-rw-r--r--lib/WWW/FairViewer/Videos.pm105
13 files changed, 1657 insertions, 225 deletions
diff --git a/lib/WWW/FairViewer/Channels.pm b/lib/WWW/FairViewer/Channels.pm
index 3ee44d4..55598d0 100644
--- a/lib/WWW/FairViewer/Channels.pm
+++ b/lib/WWW/FairViewer/Channels.pm
@@ -25,12 +25,18 @@ sub _make_channels_url {
sub videos_from_channel_id {
my ($self, $channel_id) = @_;
- return $self->_get_results($self->_make_feed_url("channels/$channel_id/videos"));
+
+ if (my $results = $self->yt_channel_uploads($channel_id)) {
+ return $results;
+ }
+
+ my $url = $self->_make_feed_url("channels/$channel_id/videos");
+ return $self->_get_results($url);
}
sub videos_from_username {
my ($self, $channel_id) = @_;
- return $self->_get_results($self->_make_feed_url("channels/$channel_id/videos"));
+ $self->videos_from_channel_id($channel_id);
}
=head2 popular_videos($channel_id)
@@ -46,7 +52,12 @@ sub popular_videos {
return $self->_get_results($self->_make_feed_url('popular'));
}
- return $self->_get_results($self->_make_feed_url("channels/$channel_id/videos", sort_by => 'popular'));
+ if (my $results = $self->yt_channel_uploads($channel_id, sort_by => 'popular')) {
+ return $results;
+ }
+
+ my $url = $self->_make_feed_url("channels/$channel_id/videos", sort_by => 'popular');
+ return $self->_get_results($url);
}
=head2 channels_from_categoryID($category_id)
@@ -94,65 +105,34 @@ For all functions, C<$channels->{results}{items}> contains:
}
}
-=head2 my_channel()
-
-Returns info about the channel of the current authenticated user.
-
-=cut
-
-sub my_channel {
- my ($self) = @_;
- $self->get_access_token() // return;
- return $self->_get_results($self->_make_channels_url(part => 'snippet', mine => 'true'));
-}
-
-=head2 my_channel_id()
+=head2 channel_id_from_username($username)
-Returns the channel ID of the current authenticated user.
+Return the channel ID for an username.
=cut
-sub my_channel_id {
- my ($self) = @_;
+sub channel_id_from_username {
+ my ($self, $username) = @_;
state $cache = {};
- if (exists $cache->{id}) {
- return $cache->{id};
+ if (exists $cache->{username}) {
+ return $cache->{username};
}
- $cache->{id} = undef;
- my $channel = $self->my_channel() // return;
- $cache->{id} = $channel->{results}{items}[0]{id} // return;
-}
-
-=head2 channels_my_subscribers()
-
-Retrieve a list of channels that subscribed to the authenticated user's channel.
-
-=cut
-
-sub channels_my_subscribers {
- my ($self) = @_;
- $self->get_access_token() // return;
- return $self->_get_results($self->_make_channels_url(mySubscribers => 'true'));
-}
-
-=head2 channel_id_from_username($username)
-
-Return the channel ID for an username.
-
-=cut
-
-sub channel_id_from_username {
- my ($self, $username) = @_;
+ if (defined(my $id = $self->yt_channel_id($username))) {
+ if (ref($id) eq '' and $id =~ /\S/) {
+ $cache->{$username} = $id;
+ return $id;
+ }
+ }
# A channel's username (if it doesn't include spaces) is also valid in place of ucid.
if ($username =~ /\w/ and not $username =~ /\s/) {
return $username;
}
- # TODO: resolve channel name to channel ID
+ # Unable to resolve channel name to channel ID (return as it is)
return $username;
}
@@ -165,11 +145,22 @@ Return the channel title for a given channel ID.
sub channel_title_from_id {
my ($self, $channel_id) = @_;
- if ($channel_id eq 'mine') {
- $channel_id = $self->my_channel_id();
+ $channel_id // return;
+
+ state $cache = {};
+
+ if (exists $cache->{channel_id}) {
+ return $cache->{channel_id};
+ }
+
+ if (defined(my $title = $self->yt_channel_title($channel_id))) {
+ if (ref($title) eq '' and $title =~ /\S/) {
+ $cache->{$channel_id} = $title;
+ return $title;
+ }
}
- my $info = $self->channels_info($channel_id // return) // return;
+ my $info = $self->channels_info($channel_id) // return;
( ref($info) eq 'HASH'
and ref($info->{results}) eq 'HASH'
diff --git a/lib/WWW/FairViewer/CommentThreads.pm b/lib/WWW/FairViewer/CommentThreads.pm
index 760756e..9bceff5 100644
--- a/lib/WWW/FairViewer/CommentThreads.pm
+++ b/lib/WWW/FairViewer/CommentThreads.pm
@@ -73,6 +73,7 @@ Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
Jesus, C<< <echo aGVja3llbEBoeXBlcmJvbGEuaW5mbw== | base64 -d> >>
+
=head1 SUPPORT
You can find documentation for this module with the perldoc command.
diff --git a/lib/WWW/FairViewer/GetCaption.pm b/lib/WWW/FairViewer/GetCaption.pm
index 710a2af..d919fe9 100644
--- a/lib/WWW/FairViewer/GetCaption.pm
+++ b/lib/WWW/FairViewer/GetCaption.pm
@@ -182,17 +182,6 @@ sub xml2srt {
return join("\n\n", @text);
}
-=head2 get_xml_data($caption_data)
-
-Get the XML content for a given caption data.
-
-=cut
-
-sub get_xml_data {
- my ($self, $url) = @_;
- $self->{yv_obj}->lwp_get($url, simple => 1);
-}
-
=head2 save_caption($video_ID)
Save the caption in a .srt file and return its file path.
@@ -213,8 +202,9 @@ sub save_caption {
return $srt_file if (-e $srt_file);
# Get XML data, then transform it to SubRip data
- my $xml = $self->get_xml_data($info->{baseUrl} // return) // return;
- my $srt = $self->xml2srt($xml) // return;
+ my $url = $info->{baseUrl} // return;
+ my $xml = $self->{yv_obj}->lwp_get($url, simple => 1) // return;
+ my $srt = $self->xml2srt($xml) // return;
# Write the SubRib data to the $srt_file
open(my $fh, '>:utf8', $srt_file) or return;
diff --git a/lib/WWW/FairViewer/GuideCategories.pm b/lib/WWW/FairViewer/GuideCategories.pm
index cead9f6..86dfe0f 100644
--- a/lib/WWW/FairViewer/GuideCategories.pm
+++ b/lib/WWW/FairViewer/GuideCategories.pm
@@ -64,6 +64,7 @@ Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
Jesus, C<< <echo aGVja3llbEBoeXBlcmJvbGEuaW5mbw== | base64 -d> >>
+
=head1 SUPPORT
You can find documentation for this module with the perldoc command.
diff --git a/lib/WWW/FairViewer/InitialData.pm b/lib/WWW/FairViewer/InitialData.pm
new file mode 100644
index 0000000..50ea500
--- /dev/null
+++ b/lib/WWW/FairViewer/InitialData.pm
@@ -0,0 +1,1050 @@
+package WWW::FairViewer::InitialData;
+
+use utf8;
+use 5.014;
+use warnings;
+
+=head1 NAME
+
+WWW::FairViewer::InitialData - Extract initial data.
+
+=head1 SYNOPSIS
+
+ use WWW::FairViewer;
+ my $obj = WWW::FairViewer->new(%opts);
+
+ my $results = $obj->yt_search(q => $keywords);
+ my $playlists = $obj->yt_channel_playlists($channel_ID);
+
+=head1 SUBROUTINES/METHODS
+
+=cut
+
+sub _time_to_seconds {
+ my ($time) = @_;
+
+ my ($hours, $minutes, $seconds) = (0, 0, 0);
+
+ if ($time =~ /(\d+):(\d+):(\d+)/) {
+ ($hours, $minutes, $seconds) = ($1, $2, $3);
+ }
+ elsif ($time =~ /(\d+):(\d+)/) {
+ ($minutes, $seconds) = ($1, $2);
+ }
+ elsif ($time =~ /(\d+)/) {
+ $seconds = $1;
+ }
+
+ $hours * 3600 + $minutes * 60 + $seconds;
+}
+
+sub _human_number_to_int {
+ my ($text) = @_;
+
+ # 7.6K -> 7600; 7.6M -> 7600000
+ if ($text =~ /([\d,.]+)\s*([KMB])/i) {
+
+ my $v = $1;
+ my $u = $2;
+ my $m = ($u eq 'K' ? 1e3 : ($u eq 'M' ? 1e6 : ($u eq 'B' ? 1e9 : 1)));
+
+ $v =~ tr/,/./;
+
+ return int($v * $m);
+ }
+
+ if ($text =~ /([\d,.]+)/) {
+ my $v = $1;
+ $v =~ tr/,.//d;
+ return int($v);
+ }
+
+ return 0;
+}
+
+sub _thumbnail_quality {
+ my ($width) = @_;
+
+ $width // return 'medium';
+
+ if ($width == 1280) {
+ return "maxres";
+ }
+
+ if ($width == 640) {
+ return "sddefault";
+ }
+
+ if ($width == 480) {
+ return 'high';
+ }
+
+ if ($width == 320) {
+ return 'medium';
+ }
+
+ if ($width == 120) {
+ return 'default';
+ }
+
+ if ($width <= 120) {
+ return 'small';
+ }
+
+ if ($width <= 176) {
+ return 'medium';
+ }
+
+ if ($width <= 480) {
+ return 'high';
+ }
+
+ if ($width <= 640) {
+ return 'sddefault';
+ }
+
+ if ($width <= 1280) {
+ return "maxres";
+ }
+
+ return 'medium';
+}
+
+sub _fix_url_protocol {
+ my ($url) = @_;
+
+ $url // return undef;
+
+ if ($url =~ m{^https://}) { # ok
+ return $url;
+ }
+ if ($url =~ s{^.*?//}{}) {
+ return "https://" . $url;
+ }
+ if ($url =~ /^\w+\./) {
+ return "https://" . $url;
+ }
+
+ return $url;
+}
+
+sub _unscramble {
+ my ($str) = @_;
+
+ my $i = my $l = length($str);
+
+ $str =~ s/(.)(.{$i})/$2$1/sg while (--$i > 0);
+ $str =~ s/(.)(.{$i})/$2$1/sg while (++$i < $l);
+
+ return $str;
+}
+
+sub _extract_youtube_mix {
+ my ($self, $data) = @_;
+
+ my $info = eval { $data->{callToAction}{watchCardHeroVideoRenderer} } || return;
+ my $header = eval { $data->{header}{watchCardRichHeaderRenderer} };
+
+ my %mix;
+
+ $mix{type} = 'playlist';
+
+ $mix{title} =
+ eval { $header->{title}{runs}[0]{text} }
+ // eval { $info->{accessibility}{accessibilityData}{label} }
+ // eval { $info->{callToActionButton}{callToActionButtonRenderer}{label}{runs}[0]{text} } // 'Youtube Mix';
+
+ $mix{playlistId} = eval { $info->{navigationEndpoint}{watchEndpoint}{playlistId} } || return;
+
+ $mix{playlistThumbnail} = eval { _fix_url_protocol($header->{avatar}{thumbnails}[0]{url}) }
+ // eval { _fix_url_protocol($info->{heroImage}{collageHeroImageRenderer}{leftThumbnail}{thumbnails}[0]{url}) };
+
+ $mix{description} = _extract_description({title => $info});
+
+ $mix{author} = eval { $header->{title}{runs}[0]{text} } // "YouTube";
+ $mix{authorId} = eval { $header->{titleNavigationEndpoint}{browseEndpoint}{browseId} } // "youtube";
+
+ return \%mix;
+}
+
+sub _extract_author_name {
+ my ($info) = @_;
+ eval { $info->{longBylineText}{runs}[0]{text} } // eval { $info->{shortBylineText}{runs}[0]{text} };
+}
+
+sub _extract_video_id {
+ my ($info) = @_;
+ eval { $info->{videoId} } || eval { $info->{navigationEndpoint}{watchEndpoint}{videoId} } || undef;
+}
+
+sub _extract_length_seconds {
+ my ($info) = @_;
+ eval { $info->{lengthSeconds} }
+ || _time_to_seconds(eval { $info->{thumbnailOverlays}[0]{thumbnailOverlayTimeStatusRenderer}{text}{runs}[0]{text} } // 0)
+ || _time_to_seconds(eval { $info->{lengthText}{runs}[0]{text} // 0 });
+}
+
+sub _extract_published_text {
+ my ($info) = @_;
+
+ my $text = eval { $info->{publishedTimeText}{runs}[0]{text} } || return undef;
+
+ if ($text =~ /(\d+)\s+(\w+)/) {
+ return "$1 $2 ago";
+ }
+
+ if ($text =~ /(\d+)\s*(\w+)/) {
+ return "$1 $2 ago";
+ }
+
+ return $text;
+}
+
+sub _extract_channel_id {
+ my ($info) = @_;
+ eval { $info->{channelId} }
+ // eval { $info->{shortBylineText}{runs}[0]{navigationEndpoint}{browseEndpoint}{browseId} }
+ // eval { $info->{navigationEndpoint}{browseEndpoint}{browseId} };
+}
+
+sub _extract_view_count_text {
+ my ($info) = @_;
+ eval { $info->{shortViewCountText}{runs}[0]{text} };
+}
+
+sub _extract_thumbnails {
+ my ($info) = @_;
+ eval {
+ [
+ map {
+ my %thumb = %$_;
+ $thumb{quality} = _thumbnail_quality($thumb{width});
+ $thumb{url} = _fix_url_protocol($thumb{url});
+ \%thumb;
+ } @{$info->{thumbnail}{thumbnails}}
+ ]
+ };
+}
+
+sub _extract_playlist_thumbnail {
+ my ($info) = @_;
+ eval {
+ _fix_url_protocol(
+ (
+ grep { _thumbnail_quality($_->{width}) =~ /medium|high/ }
+ @{$info->{thumbnailRenderer}{playlistVideoThumbnailRenderer}{thumbnail}{thumbnails}}
+ )[0]{url} // $info->{thumbnailRenderer}{playlistVideoThumbnailRenderer}{thumbnail}{thumbnails}[0]{url}
+ );
+ } // eval {
+ _fix_url_protocol((grep { _thumbnail_quality($_->{width}) =~ /medium|high/ } @{$info->{thumbnail}{thumbnails}})[0]{url}
+ // $info->{thumbnail}{thumbnails}[0]{url});
+ };
+}
+
+sub _extract_title {
+ my ($info) = @_;
+ eval { $info->{title}{runs}[0]{text} } // eval { $info->{title}{accessibility}{accessibilityData}{label} };
+}
+
+sub _extract_description {
+ my ($info) = @_;
+
+ # FIXME: this is not the video description
+ eval { $info->{title}{accessibility}{accessibilityData}{label} };
+}
+
+sub _extract_view_count {
+ my ($info) = @_;
+ _human_number_to_int(eval { $info->{viewCountText}{runs}[0]{text} } || 0);
+}
+
+sub _extract_video_count {
+ my ($info) = @_;
+ _human_number_to_int( eval { $info->{videoCountShortText}{runs}[0]{text} }
+ || eval { $info->{videoCountText}{runs}[0]{text} }
+ || 0);
+}
+
+sub _extract_subscriber_count {
+ my ($info) = @_;
+ _human_number_to_int(eval { $info->{subscriberCountText}{runs}[0]{text} } || 0);
+}
+
+sub _extract_playlist_id {
+ my ($info) = @_;
+ eval { $info->{playlistId} };
+}
+
+sub _extract_itemSection_entry {
+ my ($self, $data, %args) = @_;
+
+ ref($data) eq 'HASH' or return;
+
+ # Album
+ if ($args{type} eq 'all' and exists $data->{horizontalCardListRenderer}) { # TODO
+ return;
+ }
+
+ # Video
+ if (exists($data->{compactVideoRenderer}) or exists($data->{playlistVideoRenderer})) {
+
+ my %video;
+ my $info = $data->{compactVideoRenderer} // $data->{playlistVideoRenderer};
+
+ $video{type} = 'video';
+
+ # Deleted video
+ if (defined(eval { $info->{isPlayable} }) and not $info->{isPlayable}) {
+ return;
+ }
+
+ $video{videoId} = _extract_video_id($info) // return;
+ $video{title} = _extract_title($info) // return;
+ $video{lengthSeconds} = _extract_length_seconds($info) || 0;
+ $video{liveNow} = ($video{lengthSeconds} == 0);
+ $video{author} = _extract_author_name($info);
+ $video{authorId} = _extract_channel_id($info);
+ $video{publishedText} = _extract_published_text($info);
+ $video{viewCountText} = _extract_view_count_text($info);
+ $video{videoThumbnails} = _extract_thumbnails($info);
+ $video{description} = _extract_description($info);
+ $video{viewCount} = _extract_view_count($info);
+
+ # Filter out private/deleted videos from playlists
+ if (exists($data->{playlistVideoRenderer})) {
+ $video{author} // return;
+ $video{authorId} // return;
+ }
+
+ return \%video;
+ }
+
+ # Playlist
+ if ($args{type} ne 'video' and exists $data->{compactPlaylistRenderer}) {
+
+ my %playlist;
+ my $info = $data->{compactPlaylistRenderer};
+
+ $playlist{type} = 'playlist';
+
+ $playlist{title} = _extract_title($info) // return;
+ $playlist{playlistId} = _extract_playlist_id($info) // return;
+ $playlist{author} = _extract_author_name($info);
+ $playlist{authorId} = _extract_channel_id($info);
+ $playlist{videoCount} = _extract_video_count($info);
+ $playlist{playlistThumbnail} = _extract_playlist_thumbnail($info);
+ $playlist{description} = _extract_description($info);
+
+ return \%playlist;
+ }
+
+ # Channel
+ if ($args{type} ne 'video' and exists $data->{compactChannelRenderer}) {
+
+ my %channel;
+ my $info = $data->{compactChannelRenderer};
+
+ $channel{type} = 'channel';
+
+ $channel{author} = _extract_title($info) // return;
+ $channel{authorId} = _extract_channel_id($info) // return;
+ $channel{subCount} = _extract_subscriber_count($info);
+ $channel{videoCount} = _extract_video_count($info);
+ $channel{authorThumbnails} = _extract_thumbnails($info);
+ $channel{description} = _extract_description($info);
+
+ return \%channel;
+ }
+
+ return;
+}
+
+sub _parse_itemSection {
+ my ($self, $entry, %args) = @_;
+
+ eval { ref($entry->{contents}) eq 'ARRAY' } || return;
+
+ my @results;
+
+ foreach my $entry (@{$entry->{contents}}) {
+
+ my $item = $self->_extract_itemSection_entry($entry, %args);
+
+ if (defined($item) and ref($item) eq 'HASH') {
+ push @results, $item;
+ }
+ }
+
+ if (exists($entry->{continuations}) and ref($entry->{continuations}) eq 'ARRAY') {
+
+ my $token = eval { $entry->{continuations}[0]{nextContinuationData}{continuation} };
+
+ if (defined($token)) {
+ push @results,
+ scalar {
+ type => 'nextpage',
+ token => "ytplaylist:$args{type}:$token",
+ };
+ }
+ }
+
+ return @results;
+}
+
+sub _parse_itemSection_nextpage {
+ my ($self, $entry, %args) = @_;
+
+ eval { ref($entry->{contents}) eq 'ARRAY' } || return;
+
+ foreach my $entry (@{$entry->{contents}}) {
+
+ # Continuation page
+ if (exists $entry->{continuationItemRenderer}) {
+
+ my $info = $entry->{continuationItemRenderer};
+ my $token = eval { $info->{continuationEndpoint}{continuationCommand}{token} };
+
+ if (defined($token)) {
+ return
+ scalar {
+ type => 'nextpage',
+ token => "ytbrowse:$args{type}:$token",
+ };
+ }
+ }
+ }
+
+ return;
+}
+
+sub _extract_sectionList_results {
+ my ($self, $data, %args) = @_;
+
+ eval { ref($data->{contents}) eq 'ARRAY' } or return;
+
+ my @results;
+
+ foreach my $entry (@{$data->{contents}}) {
+
+ # Playlists
+ if (eval { ref($entry->{shelfRenderer}{content}{verticalListRenderer}{items}) eq 'ARRAY' }) {
+ my $res = {contents => $entry->{shelfRenderer}{content}{verticalListRenderer}{items}};
+ push @results, $self->_parse_itemSection($res, %args);
+ push @results, $self->_parse_itemSection_nextpage($res, %args);
+ next;
+ }
+
+ # Playlist videos
+ if (eval { ref($entry->{itemSectionRenderer}{contents}[0]{playlistVideoListRenderer}{contents}) eq 'ARRAY' }) {
+ my $res = $entry->{itemSectionRenderer}{contents}[0]{playlistVideoListRenderer};
+ push @results, $self->_parse_itemSection($res, %args);
+ push @results, $self->_parse_itemSection_nextpage($res, %args);
+ next;
+ }
+
+ # YouTube Mix
+ if ($args{type} eq 'all' and exists $entry->{universalWatchCardRenderer}) {
+
+ my $mix = $self->_extract_youtube_mix($entry->{universalWatchCardRenderer});
+
+ if (defined($mix)) {
+ push(@results, $mix);
+ }
+ }
+
+ # Video results
+ if (exists $entry->{itemSectionRenderer}) {
+ my $res = $entry->{itemSectionRenderer};
+ push @results, $self->_parse_itemSection($res, %args);
+ push @results, $self->_parse_itemSection_nextpage($res, %args);
+ }
+
+ # Continuation page
+ if (exists $entry->{continuationItemRenderer}) {
+
+ my $info = $entry->{continuationItemRenderer};
+ my $token = eval { $info->{continuationEndpoint}{continuationCommand}{token} };
+
+ if (defined($token)) {
+ push @results,
+ scalar {
+ type => 'nextpage',
+ token => "ytsearch:$args{type}:$token",
+ };
+ }
+ }
+ }
+
+ if (@results and exists $data->{continuations}) {
+ push @results, $self->_parse_itemSection($data, %args);
+ }
+
+ return @results;
+}
+
+sub _extract_channel_header {
+ my ($self, $data, %args) = @_;
+ eval { $data->{header}{c4TabbedHeaderRenderer} } // eval { $data->{metadata}{channelMetadataRenderer} };
+}
+
+sub _add_author_to_results {
+ my ($self, $data, $results, %args) = @_;
+
+ my $header = $self->_extract_channel_header($data, %args);
+
+ my $channel_id = eval { $header->{channelId} } // eval { $header->{externalId} };
+ my $channel_name = eval { $header->{title} };
+
+ foreach my $result (@$results) {
+ if (ref($result) eq 'HASH') {
+ $result->{author} = $channel_name if defined($channel_name);
+ $result->{authorId} = $channel_id if defined($channel_id);
+ }
+ }
+
+ return 1;
+}
+
+sub _find_sectionList {
+ my ($self, $data) = @_;
+
+ eval {
+ (
+ grep {
+ eval { exists($_->{tabRenderer}{content}{sectionListRenderer}{contents}) }
+ } @{$data->{contents}{singleColumnBrowseResultsRenderer}{tabs}}
+ )[0]{tabRenderer}{content}{sectionListRenderer};
+ } // undef;
+}
+
+sub _extract_channel_uploads {
+ my ($self, $data, %args) = @_;
+
+ my @results = $self->_extract_sectionList_results($self->_find_sectionList($data), %args);
+ $self->_add_author_to_results($data, \@results, %args);
+ return @results;
+}
+
+sub _extract_channel_playlists {
+ my ($self, $data, %args) = @_;
+
+ my @results = $self->_extract_sectionList_results($self->_find_sectionList($data), %args);
+ $self->_add_author_to_results($data, \@results, %args);
+ return @results;
+}
+
+sub _extract_playlist_videos {
+ my ($self, $data, %args) = @_;
+
+ my @results = $self->_extract_sectionList_results($self->_find_sectionList($data), %args);
+ $self->_add_author_to_results($data, \@results, %args);
+ return @results;
+}
+
+sub _get_initial_data {
+ my ($self, $url) = @_;
+
+ $self->get_prefer_invidious() and return;
+
+ my $content = $self->lwp_get($url) // return;
+
+ if ($content =~ m{var\s+ytInitialData\s*=\s*'(.*?)'}is) {
+ my $json = $1;
+
+ $json =~ s{\\x([[:xdigit:]]{2})}{chr(hex($1))}ge;
+ $json =~ s{\\u([[:xdigit:]]{4})}{chr(hex($1))}ge;
+ $json =~ s{\\(["&])}{$1}g;
+
+ my $hash = $self->parse_utf8_json_string($json);
+ return $hash;
+ }
+
+ if ($content =~ m{<div id="initial-data"><!--(.*?)--></div>}is) {
+ my $json = $1;
+ my $hash = $self->parse_utf8_json_string($json);
+ return $hash;
+ }
+
+ return;
+}
+
+sub _channel_data {
+ my ($self, $channel, %args) = @_;
+
+ state $yv_utils = WWW::FairViewer::Utils->new();
+
+ my $url = $self->get_m_youtube_url;
+
+ if ($yv_utils->is_channelID($channel)) {
+ $url .= "/channel/$channel/$args{type}";
+ }
+ else {
+ $url .= "/c/$channel/$args{type}";
+ }
+
+ my %params = (hl => "en");
+
+ if (defined(my $sort = $args{sort_by})) {
+ if ($sort eq 'popular') {
+ $params{sort} = 'p';
+ }
+ elsif ($sort eq 'old') {
+ $params{sort} = 'da';
+ }
+ }
+
+ if (exists($args{params}) and ref($args{params}) eq 'HASH') {
+ %params = (%params, %{$args{params}});
+ }
+
+ $url = $self->_append_url_args($url, %params);
+ my $result = $self->_get_initial_data($url);
+
+ # When /c/ failed, try /user/
+ if ((!defined($result) or !scalar(keys %$result)) and $url =~ s{/c/}{/user/}) {
+ $result = $self->_get_initial_data($url);
+ }
+
+ ($url, $result);
+}
+
+sub _prepare_results_for_return {
+ my ($self, $results, %args) = @_;
+
+ (defined($results) and ref($results) eq 'ARRAY') || return;
+
+ my @results = @$results;
+
+ @results || return;
+
+ if (@results and $results[-1]{type} eq 'nextpage') {
+
+ my $nextpage = pop(@results);
+
+ if (defined($nextpage->{token}) and @results) {
+
+ if ($self->get_debug) {
+ say STDERR ":: Returning results with a continuation page token...";
+ }
+
+ return {
+ url => $args{url},
+ results => {
+ entries => \@results,
+ continuation => $nextpage->{token},
+ },
+ };
+ }
+ }
+
+ my $url = $args{url};
+
+ if ($url =~ m{^https://m\.youtube\.com}) {
+ $url = undef;
+ }
+
+ return {
+ url => $url,
+ results => \@results,
+ };
+}
+
+=head2 yt_search(q => $keyword, %args)
+
+Search for videos given a keyword string (uri-escaped).
+
+=cut
+
+sub yt_search {
+ my ($self, %args) = @_;
+
+ my $url = $self->get_m_youtube_url . "/results?search_query=$args{q}";
+
+ my @sp;
+ my %params = (hl => 'en',);
+
+ $args{type} //= 'video';
+
+ if ($args{type} eq 'video') {
+
+ if (defined(my $duration = $self->get_videoDuration)) {
+ if ($duration eq 'long') {
+ push @sp, 'EgQQARgC';
+ }
+ elsif ($duration eq 'short') {
+ push @sp, 'EgQQARgB';
+ }
+ }
+
+ if (defined(my $date = $self->get_date)) {
+ if ($date eq 'hour') {
+ push @sp, 'EgQIARAB';
+ }
+ elsif ($date eq 'today') {
+ push @sp, "EgQIAhAB";
+ }
+ elsif ($date eq 'week') {
+ push @sp, "EgQIAxAB";
+ }
+ elsif ($date eq 'month') {
+ push @sp, "EgQIBBAB";
+ }
+ elsif ($date eq 'year') {
+ push @sp, "EgQIBRAB";
+ }
+ }
+
+ if (defined(my $order = $self->get_order)) {
+ if ($order eq 'upload_date') {
+ push @sp, "CAISAhAB";
+ }
+ elsif ($order eq 'view_count') {
+ push @sp, "CAMSAhAB";
+ }
+ elsif ($order eq 'rating') {
+ push @sp, "CAESAhAB";
+ }
+ }
+
+ if (defined(my $license = $self->get_videoLicense)) {
+ if ($license eq 'creative_commons') {
+ push @sp, "EgIwAQ%253D%253D";
+ }
+ }
+
+ if (defined(my $vd = $self->get_videoDefinition)) {
+ if ($vd eq 'high') {
+ push @sp, "EgIgAQ%253D%253D";
+ }
+ }
+
+ if (defined(my $vc = $self->get_videoCaption)) {
+ if ($vc eq 'true' or $vc eq '1') {
+ push @sp, "EgIoAQ%253D%253D";
+ }
+ }
+
+ if (defined(my $vd = $self->get_videoDimension)) {
+ if ($vd eq '3d') {
+ push @sp, "EgI4AQ%253D%253D";
+ }
+ }
+ }
+
+ if ($args{type} eq 'video') {
+ push @sp, "EgIQAQ%253D%253D";
+ }
+ elsif ($args{type} eq 'playlist') {
+ push @sp, "EgIQAw%253D%253D";
+ }
+ elsif ($args{type} eq 'channel') {
+ push @sp, "EgIQAg%253D%253D";
+ }
+ elsif ($args{type} eq 'movie') { # TODO: implement support for movies
+ push @sp, "EgIQBA%253D%253D";
+ }
+
+ $params{sp} = join('+', @sp);
+ $url = $self->_append_url_args($url, %params);
+
+ my $hash = $self->_get_initial_data($url) // return;
+ my @results = $self->_extract_sectionList_results(eval { $hash->{contents}{sectionListRenderer} } // undef, %args);
+
+ $self->_prepare_results_for_return(\@results, %args, url => $url);
+}
+
+=head2 yt_channel_search($channel, q => $keyword, %args)
+
+Search for videos given a keyword string (uri-escaped) from a given channel ID or username.
+
+=cut
+
+sub yt_channel_search {
+ my ($self, $channel, %args) = @_;
+ my ($url, $hash) = $self->_channel_data($channel, %args, type => 'search', params => {query => $args{q}});
+
+ $hash // return;
+
+ my @results = $self->_extract_sectionList_results($self->_find_sectionList($hash), %args, type => 'video');
+ $self->_prepare_results_for_return(\@results, %args, url => $url);
+}
+
+=head2 yt_channel_uploads($channel, %args)
+
+Latest uploads for a given channel ID or username.
+
+=cut
+
+sub yt_channel_uploads {
+ my ($self, $channel, %args) = @_;
+ my ($url, $hash) = $self->_channel_data($channel, %args, type => 'videos');
+
+ $hash // return;
+
+ my @results = $self->_extract_channel_uploads($hash, %args, type => 'video');
+ $self->_prepare_results_for_return(\@results, %args, url => $url);
+}
+
+=head2 yt_channel_info($channel, %args)
+
+Channel info (such as title) for a given channel ID or username.
+
+=cut
+
+sub yt_channel_info {
+ my ($self, $channel, %args) = @_;
+ my ($url, $hash) = $self->_channel_data($channel, %args, type => '');
+ return $hash;
+}
+
+=head2 yt_channel_title($channel, %args)
+
+Exact the channel title (as a string) for a given channel ID or username.
+
+=cut
+
+sub yt_channel_title {
+ my ($self, $channel, %args) = @_;
+ my ($url, $hash) = $self->_channel_data($channel, %args, type => '');
+ $hash // return;
+ my $header = $self->_extract_channel_header($hash, %args) // return;
+ my $title = eval { $header->{title} };
+ return $title;
+}
+
+=head2 yt_channel_id($username, %args)
+
+Exact the channel ID (as a string) for a given channel username.
+
+=cut
+
+sub yt_channel_id {
+ my ($self, $username, %args) = @_;
+ my ($url, $hash) = $self->_channel_data($username, %args, type => '');
+ $hash // return;
+ my $header = $self->_extract_channel_header($hash, %args) // return;
+ my $id = eval { $header->{channelId} } // eval { $header->{externalId} };
+ return $id;
+}
+
+=head2 yt_channel_playlists($channel, %args)
+
+Playlists for a given channel ID or username.
+
+=cut
+
+sub yt_channel_playlists {
+ my ($self, $channel, %args) = @_;
+ my ($url, $hash) = $self->_channel_data($channel, %args, type => 'playlists');
+
+ $hash // return;
+
+ my @results = $self->_extract_channel_playlists($hash, %args, type => 'playlist');
+ $self->_prepare_results_for_return(\@results, %args, url => $url);
+}
+
+=head2 yt_playlist_videos($playlist_id, %args)
+
+Videos from a given playlist ID.
+
+=cut
+
+sub yt_playlist_videos {
+ my ($self, $playlist_id, %args) = @_;
+
+ my $url = $self->_append_url_args($self->get_m_youtube_url . "/playlist", list => $playlist_id, hl => "en");
+ my $hash = $self->_get_initial_data($url) // return;
+
+ my @results = $self->_extract_sectionList_results($self->_find_sectionList($hash), %args, type => 'video');
+ $self->_prepare_results_for_return(\@results, %args, url => $url);
+}
+
+=head2 yt_playlist_next_page($url, $token, %args)
+
+Load more items from a playlist, given a continuation token.
+
+=cut
+
+sub yt_playlist_next_page {
+ my ($self, $url, $token, %args) = @_;
+
+ my $request_url = $self->_append_url_args($url, ctoken => $token);
+ my $hash = $self->_get_initial_data($request_url) // return;
+
+ my @results = $self->_parse_itemSection(
+ eval { $hash->{continuationContents}{playlistVideoListContinuation} }
+ // eval { $hash->{continuationContents}{itemSectionContinuation} },
+ %args
+ );
+
+ if (!@results) {
+ @results =
+ $self->_extract_sectionList_results(eval { $hash->{continuationContents}{sectionListContinuation} } // undef, %args);
+ }
+
+ $self->_add_author_to_results($hash, \@results, %args);
+ $self->_prepare_results_for_return(\@results, %args, url => $url);
+}
+
+sub yt_browse_next_page {
+ my ($self, $url, $token, %args) = @_;
+
+ my %request = (
+ context => {
+ client => {
+ browserName => "Firefox",
+ browserVersion => "83.0",
+ clientFormFactor => "LARGE_FORM_FACTOR",
+ clientName => "MWEB",
+ clientVersion => "2.20210308.03.00",
+ deviceMake => "Generic",
+ deviceModel => "Android 11.0",
+ hl => "en",
+ mainAppWebInfo => {
+ graftUrl => $url,
+ },
+ originalUrl => $url,
+ osName => "Android",
+ osVersion => "11",
+ platform => "TABLET",
+ playerType => "UNIPLAYER",
+ screenDensityFloat => 1,
+ screenHeightPoints => 500,
+ screenPixelDensity => 1,
+ screenWidthPoints => 1800,
+ timeZone => "UTC",
+ userAgent => "Mozilla/5.0 (Android 11; Tablet; rv:83.0) Gecko/83.0 Firefox/83.0,gzip(gfe)",
+ userInterfaceTheme => "USER_INTERFACE_THEME_LIGHT",
+ utcOffsetMinutes => 0,
+ },
+ request => {
+ consistencyTokenJars => [],
+ internalExperimentFlags => [],
+ },
+ user => {},
+ },
+ continuation => $token,
+ );
+
+ my $content = $self->post_as_json(
+ $self->get_m_youtube_url . '/youtubei/v1/browse?key=' . _unscramble('1HUCiSlOalFEcYQSS8_9q1LW4y8JAwI2zT_qA_G'),
+ \%request) // return;
+
+ my $hash = $self->parse_json_string($content);
+
+ my $res =
+ eval { $hash->{continuationContents}{playlistVideoListContinuation} }
+ // eval { $hash->{continuationContents}{itemSectionContinuation} }
+ // eval { {contents => $hash->{onResponseReceivedActions}[0]{appendContinuationItemsAction}{continuationItems}} }
+ // undef;
+
+ my @results = $self->_parse_itemSection($res, %args);
+
+ if (@results) {
+ push @results, $self->_parse_itemSection_nextpage($res, %args);
+ }
+
+ if (!@results) {
+ @results =
+ $self->_extract_sectionList_results(eval { $hash->{continuationContents}{sectionListContinuation} } // undef, %args);
+ }
+
+ $self->_add_author_to_results($hash, \@results, %args);
+ $self->_prepare_results_for_return(\@results, %args, url => $url);
+}
+
+=head2 yt_search_next_page($url, $token, %args)
+
+Load more search results, given a continuation token.
+
+=cut
+
+sub yt_search_next_page {
+ my ($self, $url, $token, %args) = @_;
+
+ my %request = (
+ "context" => {
+ "client" => {
+ "browserName" => "Firefox",
+ "browserVersion" => "83.0",
+ "clientFormFactor" => "LARGE_FORM_FACTOR",
+ "clientName" => "MWEB",
+ "clientVersion" => "2.20201030.01.00",
+ "deviceMake" => "generic",
+ "deviceModel" => "android 11.0",
+ "gl" => "US",
+ "hl" => "en",
+ "mainAppWebInfo" => {
+ "graftUrl" => "https://m.youtube.com/results?search_query=youtube"
+ },
+ "osName" => "Android",
+ "osVersion" => "11",
+ "platform" => "TABLET",
+ "playerType" => "UNIPLAYER",
+ "screenDensityFloat" => 1,
+ "screenHeightPoints" => 420,
+ "screenPixelDensity" => 1,
+ "screenWidthPoints" => 1442,
+ "userAgent" => "Mozilla/5.0 (Android 11; Tablet; rv:83.0) Gecko/83.0 Firefox/83.0,gzip(gfe)",
+ "userInterfaceTheme" => "USER_INTERFACE_THEME_LIGHT",
+ "utcOffsetMinutes" => 0,
+ },
+ "request" => {
+ "consistencyTokenJars" => [],
+ "internalExperimentFlags" => [],
+ },
+ "user" => {}
+ },
+ "continuation" => $token,
+ );
+
+ my $content = $self->post_as_json(
+ $self->get_m_youtube_url
+ . _unscramble('o/ebseky?u1ri//hvcuyta=e')
+ . _unscramble('1HUCiSlOalFEcYQSS8_9q1LW4y8JAwI2zT_qA_G'),
+ \%request
+ ) // return;
+
+ my $hash = $self->parse_json_string($content);
+
+ my @results = $self->_extract_sectionList_results(
+ scalar {
+ contents => eval {
+ $hash->{onResponseReceivedCommands}[0]{appendContinuationItemsAction}{continuationItems};
+ } // undef
+ },
+ %args
+ );
+
+ $self->_prepare_results_for_return(\@results, %args, url => $url);
+}
+
+=head1 AUTHOR
+
+Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
+Jesus, C<< <echo aGVja3llbEBoeXBlcmJvbGEuaW5mbw== | base64 -d> >>
+
+
+=head1 SUPPORT
+
+You can find documentation for this module with the perldoc command.
+
+ perldoc WWW::FairViewer::InitialData
+
+
+=head1 LICENSE AND COPYRIGHT
+
+Copyright 2013-2015 Trizen.
+
+Copyright 2020 Jesus E.
+
+This program is free software; you can redistribute it and/or modify it
+under the terms of either: the GNU General Public License as published
+by the Free Software Foundation; or the Artistic License.
+
+See L<http://dev.perl.org/licenses/> for more information.
+
+=cut
+
+1; # End of WWW::FairViewer::InitialData
diff --git a/lib/WWW/FairViewer/Itags.pm b/lib/WWW/FairViewer/Itags.pm
index 85473c2..856775c 100644
--- a/lib/WWW/FairViewer/Itags.pm
+++ b/lib/WWW/FairViewer/Itags.pm
@@ -41,90 +41,90 @@ Reference: http://en.wikipedia.org/wiki/YouTube#Quality_and_formats
sub get_itags {
scalar {
- 'best' => [{value => 38, format => 'mp4'}, # mp4 (3072p) (v-a)
- {value => 138, format => 'mp4', dash => 1}, # mp4 (2160p-4320p) (v)
- {value => 266, format => 'mp4', dash => 1}, # mp4 (2160p-2304p) (v)
+ 'best' => [{value => 38, format => 'mp4'}, # mp4 (3072p) (v-a)
+ {value => 138, format => 'mp4', split => 1}, # mp4 (2160p-4320p) (v)
+ {value => 266, format => 'mp4', split => 1}, # mp4 (2160p-2304p) (v)
],
- '2160' => [{value => 315, format => 'webm', dash => 1, hfr => 1}, # webm HFR (v)
- {value => 272, format => 'webm', dash => 1}, # webm (v)
- {value => 313, format => 'webm', dash => 1}, # webm (v)
- {value => 401, format => 'av1', dash => 1}, # av1 (v)
+ '2160' => [{value => 315, format => 'webm', split => 1, hfr => 1}, # webm HFR (v)
+ {value => 272, format => 'webm', split => 1}, # webm (v)
+ {value => 313, format => 'webm', split => 1}, # webm (v)
+ {value => 401, format => 'av1', split => 1}, # av1 (v)
],
- '1440' => [{value => 308, format => 'webm', dash => 1, hfr => 1}, # webm HFR (v)
- {value => 271, format => 'webm', dash => 1}, # webm (v)
- {value => 264, format => 'mp4', dash => 1}, # mp4 (v)
- {value => 400, format => 'av1', dash => 1}, # av1 (v)
+ '1440' => [{value => 308, format => 'webm', split => 1, hfr => 1}, # webm HFR (v)
+ {value => 271, format => 'webm', split => 1}, # webm (v)
+ {value => 264, format => 'mp4', split => 1}, # mp4 (v)
+ {value => 400, format => 'av1', split => 1}, # av1 (v)
],
- '1080' => [{value => 303, format => 'webm', dash => 1, hfr => 1}, # webm HFR (v)
- {value => 299, format => 'mp4', dash => 1, hfr => 1}, # mp4 HFR (v)
- {value => 248, format => 'webm', dash => 1}, # webm (v)
- {value => 137, format => 'mp4', dash => 1}, # mp4 (v)
- {value => 399, format => 'av1', dash => 1, hfr => 1}, # av1 (v)
- {value => 46, format => 'webm'}, # webm (v-a)
- {value => 37, format => 'mp4'}, # mp4 (v-a)
- {value => 301, format => 'mp4', live => 1}, # mp4 (live) (v-a)
- {value => 96, format => 'ts', live => 1}, # ts (live) (v-a)
+ '1080' => [{value => 303, format => 'webm', split => 1, hfr => 1}, # webm HFR (v)
+ {value => 299, format => 'mp4', split => 1, hfr => 1}, # mp4 HFR (v)
+ {value => 248, format => 'webm', split => 1}, # webm (v)
+ {value => 137, format => 'mp4', split => 1}, # mp4 (v)
+ {value => 399, format => 'av1', split => 1, hfr => 1}, # av1 (v)
+ {value => 46, format => 'webm'}, # webm (v-a)
+ {value => 37, format => 'mp4'}, # mp4 (v-a)
+ {value => 301, format => 'mp4', live => 1}, # mp4 (live) (v-a)
+ {value => 96, format => 'ts', live => 1}, # ts (live) (v-a)
],
- '720' => [{value => 302, format => 'webm', dash => 1, hfr => 1}, # webm HFR (v)
- {value => 298, format => 'mp4', dash => 1, hfr => 1}, # mp4 HFR (v)
- {value => 247, format => 'webm', dash => 1}, # webm (v)
- {value => 136, format => 'mp4', dash => 1}, # mp4 (v)
- {value => 398, format => 'av1', dash => 1, hfr => 1}, # av1 (v)
- {value => 45, format => 'webm'}, # webm (v-a)
- {value => 22, format => 'mp4'}, # mp4 (v-a)
- {value => 300, format => 'mp4', live => 1}, # mp4 (live) (v-a)
- {value => 120, format => 'flv', live => 1}, # flv (live) (v-a)
- {value => 95, format => 'ts', live => 1}, # ts (live) (v-a)
+ '720' => [{value => 302, format => 'webm', split => 1, hfr => 1}, # webm HFR (v)
+ {value => 298, format => 'mp4', split => 1, hfr => 1}, # mp4 HFR (v)
+ {value => 247, format => 'webm', split => 1}, # webm (v)
+ {value => 136, format => 'mp4', split => 1}, # mp4 (v)
+ {value => 398, format => 'av1', split => 1, hfr => 1}, # av1 (v)
+ {value => 45, format => 'webm'}, # webm (v-a)
+ {value => 22, format => 'mp4'}, # mp4 (v-a)
+ {value => 300, format => 'mp4', live => 1}, # mp4 (live) (v-a)
+ {value => 120, format => 'flv', live => 1}, # flv (live) (v-a)
+ {value => 95, format => 'ts', live => 1}, # ts (live) (v-a)
],
- '480' => [{value => 244, format => 'webm', dash => 1}, # webm (v)
- {value => 135, format => 'mp4', dash => 1}, # mp4 (v)
- {value => 397, format => 'av1', dash => 1}, # av1 (v)
- {value => 44, format => 'webm'}, # webm (v-a)
- {value => 35, format => 'flv'}, # flv (v-a)
- {value => 94, format => 'mp4', live => 1}, # mp4 (live) (v-a)
+ '480' => [{value => 244, format => 'webm', split => 1}, # webm (v)
+ {value => 135, format => 'mp4', split => 1}, # mp4 (v)
+ {value => 397, format => 'av1', split => 1}, # av1 (v)
+ {value => 44, format => 'webm'}, # webm (v-a)
+ {value => 35, format => 'flv'}, # flv (v-a)
+ {value => 94, format => 'mp4', live => 1}, # mp4 (live) (v-a)
],
- '360' => [{value => 243, format => 'webm', dash => 1}, # webm (v)
- {value => 134, format => 'mp4', dash => 1}, # mp4 (v)
- {value => 396, format => 'av1', dash => 1}, # av1 (v)
- {value => 43, format => 'webm'}, # webm (v-a)
- {value => 34, format => 'flv'}, # flv (v-a)
- {value => 93, format => 'mp4', live => 1}, # mp4 (live) (v-a)
- {value => 18, format => 'mp4'}, # mp4 (v-a)
+ '360' => [{value => 243, format => 'webm', split => 1}, # webm (v)
+ {value => 134, format => 'mp4', split => 1}, # mp4 (v)
+ {value => 396, format => 'av1', split => 1}, # av1 (v)
+ {value => 43, format => 'webm'}, # webm (v-a)
+ {value => 34, format => 'flv'}, # flv (v-a)
+ {value => 93, format => 'mp4', live => 1}, # mp4 (live) (v-a)
+ {value => 18, format => 'mp4'}, # mp4 (v-a)
],
- '240' => [{value => 242, format => 'webm', dash => 1}, # webm (v)
- {value => 133, format => 'mp4', dash => 1}, # mp4 (v)
- {value => 395, format => 'av1', dash => 1}, # av1 (v)
- {value => 6, format => 'flv'}, # flv (270p) (v-a)
- {value => 5, format => 'flv'}, # flv (v-a)
- {value => 36, format => '3gp'}, # 3gp (v-a)
- {value => 13, format => '3gp'}, # 3gp (v-a)
- {value => 92, format => 'mp4', live => 1}, # mp4 (live) (v-a)
- {value => 132, format => 'ts', live => 1}, # ts (live) (v-a)
+ '240' => [{value => 242, format => 'webm', split => 1}, # webm (v)
+ {value => 133, format => 'mp4', split => 1}, # mp4 (v)
+ {value => 395, format => 'av1', split => 1}, # av1 (v)
+ {value => 6, format => 'flv'}, # flv (270p) (v-a)
+ {value => 5, format => 'flv'}, # flv (v-a)
+ {value => 36, format => '3gp'}, # 3gp (v-a)
+ {value => 13, format => '3gp'}, # 3gp (v-a)
+ {value => 92, format => 'mp4', live => 1}, # mp4 (live) (v-a)
+ {value => 132, format => 'ts', live => 1}, # ts (live) (v-a)
],
- '144' => [{value => 278, format => 'webm', dash => 1}, # webm (v)
- {value => 160, format => 'mp4', dash => 1}, # mp4 (v)
- {value => 394, format => 'av1', dash => 1}, # av1 (v)
- {value => 17, format => '3gp'}, # 3gp (v-a)
- {value => 91, format => 'mp4'}, # mp4 (live) (v-a)
- {value => 151, format => 'ts'}, # ts (live) (v-a)
+ '144' => [{value => 278, format => 'webm', split => 1}, # webm (v)
+ {value => 160, format => 'mp4', split => 1}, # mp4 (v)
+ {value => 394, format => 'av1', split => 1}, # av1 (v)
+ {value => 17, format => '3gp'}, # 3gp (v-a)
+ {value => 91, format => 'mp4'}, # mp4 (live) (v-a)
+ {value => 151, format => 'ts'}, # ts (live) (v-a)
],
- 'audio' => [{value => 172, format => 'webm', kbps => 192}, # webm (192 kbps)
- {value => 251, format => 'opus', kbps => 160}, # webm opus (128-160 kbps)
- {value => 171, format => 'webm', kbps => 128}, # webm vorbis (92-128 kbps)
- {value => 140, format => 'm4a', kbps => 128}, # mp4a (128 kbps)
- {value => 141, format => 'm4a', kbps => 256}, # mp4a (256 kbps)
- {value => 250, format => 'opus', kbps => 64}, # webm opus (64 kbps)
- {value => 249, format => 'opus', kbps => 48}, # webm opus (48 kbps)
- {value => 139, format => 'm4a', kbps => 48}, # mp4a (48 kbps)
+ 'audio' => [{value => 172, format => 'webm', kbps => 192}, # webm (192 kbps)
+ {value => 251, format => 'opus', kbps => 160}, # webm opus (128-160 kbps)
+ {value => 171, format => 'webm', kbps => 128}, # webm vorbis (92-128 kbps)
+ {value => 140, format => 'm4a', kbps => 128}, # mp4a (128 kbps)
+ {value => 141, format => 'm4a', kbps => 256}, # mp4a (256 kbps)
+ {value => 250, format => 'opus', kbps => 64}, # webm opus (64 kbps)
+ {value => 249, format => 'opus', kbps => 48}, # webm opus (48 kbps)
+ {value => 139, format => 'm4a', kbps => 48}, # mp4a (48 kbps)
],
};
}
@@ -176,12 +176,19 @@ sub _find_streaming_url {
$args{ignore_av1} && next; # ignore videos in AV1 format
}
- if ($itag->{dash}) {
+ # Ignored video projections
+ if (ref($args{ignored_projections}) eq 'ARRAY') {
+ if (grep { lc($entry->{projectionType} // '') eq lc($_) } @{$args{ignored_projections}}) {
+ next;
+ }
+ }
+
+ if ($itag->{split}) {
- $args{dash} || next;
+ $args{split} || next;
my $video_info = $stream->{$itag->{value}};
- my $audio_info = $self->_find_streaming_url(%args, resolution => 'audio', dash => 0);
+ my $audio_info = $self->_find_streaming_url(%args, resolution => 'audio', split => 0);
if (defined($audio_info)) {
$video_info->{__AUDIO__} = $audio_info;
@@ -191,14 +198,14 @@ sub _find_streaming_url {
next;
}
- if ($resolution eq 'audio' and not $args{dash_mp4_audio}) {
- if ($itag->{format} eq 'm4a') {
- next; # skip m4a audio URLs
+ if ($resolution eq 'audio' and $args{prefer_m4a}) {
+ if ($itag->{format} ne 'm4a') {
+ next; # skip non-M4A audio URLs
}
}
# Ignore segmented DASH URLs (they load pretty slow in mpv)
- if (not $args{dash_segmented}) {
+ if (not $args{dash}) {
next if ($entry->{url} =~ m{/api/manifest/dash/});
}
@@ -215,9 +222,12 @@ Return the streaming URL which corresponds with the specified resolution.
(
urls => \@streaming_urls,
resolution => 'resolution_name', # from $obj->get_resolutions(),
- dash => 1/0, # include or exclude DASH itags
- dash_mp4_audio => 1/0, # include or exclude DASH videos with MP4 audio
- dash_segmented => 1/0, # include or exclude segmented DASH videos
+
+ hfr => 1/0, # include or exclude High Frame Rate videos
+ ignore_av1 => 1/0, # true to ignore videos in AV1 format
+ split => 1/0, # include or exclude split videos
+ m4a_audio => 1/0, # incldue or exclude M4A audio files
+ dash => 1/0, # include or exclude streams in DASH format
)
=cut
@@ -253,11 +263,50 @@ sub find_streaming_url {
$found_resolution = $resolution;
}
- # Otherwise, find the best resolution available
- if (not defined $streaming) {
+ state $resolutions = $self->get_resolutions();
+
+ # Find the nearest available resolution
+ if (defined($resolution) and not defined($streaming)) {
+
+ my $end = $#{$resolutions} - 1; # -1 to ignore 'audio'
+
+ foreach my $i (0 .. $end) {
+ if ($resolutions->[$i] eq $resolution) {
+ for (my $k = 1 ; ; ++$k) {
+
+ if ($i + $k > $end and $i - $k < 0) {
+ last;
+ }
+
+ if ($i + $k <= $end) { # nearest below
- state $resolutions = $self->get_resolutions();
+ my $res = $resolutions->[$i + $k];
+ $streaming = $self->_find_streaming_url(%args, resolution => $res);
+ if (defined($streaming)) {
+ $found_resolution = $res;
+ last;
+ }
+ }
+
+ if ($i - $k >= 0) { # nearest above
+
+ my $res = $resolutions->[$i - $k];
+ $streaming = $self->_find_streaming_url(%args, resolution => $res);
+
+ if (defined($streaming)) {
+ $found_resolution = $res;
+ last;
+ }
+ }
+ }
+ last;
+ }
+ }
+ }
+
+ # Otherwise, find the best resolution available
+ if (not defined $streaming) {
foreach my $res (@{$resolutions}) {
$streaming = $self->_find_streaming_url(%args, resolution => $res);
diff --git a/lib/WWW/FairViewer/ParseJSON.pm b/lib/WWW/FairViewer/ParseJSON.pm
index 4945a2a..6733eb0 100644
--- a/lib/WWW/FairViewer/ParseJSON.pm
+++ b/lib/WWW/FairViewer/ParseJSON.pm
@@ -23,6 +23,18 @@ Parse a JSON string and return a HASH ref.
=cut
+sub parse_utf8_json_string {
+ my ($self, $json) = @_;
+
+ if (not defined($json) or $json eq '') {
+ return {};
+ }
+
+ require JSON;
+ my $hash = eval { JSON::from_json($json) };
+ return $@ ? do { warn "[JSON]: $@\n"; {} } : $hash;
+}
+
sub parse_json_string {
my ($self, $json) = @_;
diff --git a/lib/WWW/FairViewer/ParseXML.pm b/lib/WWW/FairViewer/ParseXML.pm
index 733c2bc..9c4fa04 100644
--- a/lib/WWW/FairViewer/ParseXML.pm
+++ b/lib/WWW/FairViewer/ParseXML.pm
@@ -287,8 +287,11 @@ sub xml2hash {
=head1 AUTHOR
+Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
Jesus, C<< <echo aGVja3llbEBoeXBlcmJvbGEuaW5mbw== | base64 -d> >>
+
=head1 SUPPORT
You can find documentation for this module with the perldoc command.
diff --git a/lib/WWW/FairViewer/PlaylistItems.pm b/lib/WWW/FairViewer/PlaylistItems.pm
index 090a4b3..5555265 100644
--- a/lib/WWW/FairViewer/PlaylistItems.pm
+++ b/lib/WWW/FairViewer/PlaylistItems.pm
@@ -80,7 +80,13 @@ Get videos from a specific playlistID.
sub videos_from_playlist_id {
my ($self, $id) = @_;
- $self->_get_results($self->_make_feed_url("playlists/$id"));
+
+ if (my $results = $self->yt_playlist_videos($id)) {
+ return $results;
+ }
+
+ my $url = $self->_make_feed_url("playlists/$id");
+ $self->_get_results($url);
}
=head2 favorites($channel_id)
diff --git a/lib/WWW/FairViewer/Playlists.pm b/lib/WWW/FairViewer/Playlists.pm
index 01277c0..4294352 100644
--- a/lib/WWW/FairViewer/Playlists.pm
+++ b/lib/WWW/FairViewer/Playlists.pm
@@ -6,7 +6,7 @@ use warnings;
=head1 NAME
-WWW::FairViewer::Playlists - Fair playlists handle.
+WWW::FairViewer::Playlists - YouTube playlists related mehods.
=head1 SYNOPSIS
@@ -25,7 +25,7 @@ sub _make_playlists_url {
$opts{'part'} = 'snippet,contentDetails';
}
- $self->_make_feed_url('playlists', %opts);
+ $self->_make_feed_url('playlists', %opts,);
}
sub get_playlist_id {
@@ -60,7 +60,13 @@ Get and return playlists from a channel ID.
sub playlists {
my ($self, $channel_id) = @_;
- $self->_get_results($self->_make_feed_url("channels/playlists/$channel_id"));
+
+ if (my $results = $self->yt_channel_playlists($channel_id)) {
+ return $results;
+ }
+
+ my $url = $self->_make_feed_url("channels/playlists/$channel_id");
+ $self->_get_results($url);
}
=head2 playlists_from_username($username)
diff --git a/lib/WWW/FairViewer/Search.pm b/lib/WWW/FairViewer/Search.pm
index 7242637..68f4521 100644
--- a/lib/WWW/FairViewer/Search.pm
+++ b/lib/WWW/FairViewer/Search.pm
@@ -6,7 +6,7 @@ use warnings;
=head1 NAME
-WWW::FairViewer::Search - Search functions for Fair API v3
+WWW::FairViewer::Search - Search for stuff on YouTube
=head1 SYNOPSIS
@@ -85,16 +85,26 @@ sub search_for {
# Search in a channel's videos
if (defined(my $channel_id = $self->get_channelId)) {
- my $url = $self->_make_feed_url("channels/search/$channel_id", q => $keywords,);
+
+ $self->set_channelId(); # clear the channel ID
+
+ if (my $results = $self->yt_channel_search($channel_id, q => $keywords, type => $type, %$args)) {
+ return $results;
+ }
+
+ my $url = $self->_make_feed_url("channels/search/$channel_id", q => $keywords);
return $self->_get_results($url);
}
+ if (my $results = $self->yt_search(q => $keywords, type => $type, %$args)) {
+ return $results;
+ }
+
my $url = $self->_make_search_url(
type => $type,
q => $keywords,
- %$args,
+ %$args
);
-
return $self->_get_results($url);
}
@@ -161,15 +171,12 @@ be set to a YouTube video ID.
sub related_to_videoID {
my ($self, $videoID) = @_;
- my %info = $self->_get_video_info($videoID);
- my $watch_next_response = $self->parse_json_string($info{watch_next_response});
+ my $watch_next_response = $self->parse_json_string($self->_get_video_next_info($videoID) // return {results => []});
+
my $related =
eval { $watch_next_response->{contents}{twoColumnWatchNextResults}{secondaryResults}{secondaryResults}{results} }
// return {results => []};
- #use Data::Dump qw(pp);
- #pp $related;
-
my @results;
foreach my $entry (@$related) {
diff --git a/lib/WWW/FairViewer/Utils.pm b/lib/WWW/FairViewer/Utils.pm
index 8cdcce3..1076af2 100644
--- a/lib/WWW/FairViewer/Utils.pm
+++ b/lib/WWW/FairViewer/Utils.pm
@@ -96,6 +96,9 @@ Returns time from seconds.
sub format_time {
my ($self, $sec) = @_;
+
+ $sec //= 0;
+
$sec >= 3600
? join q{:}, map { sprintf '%02d', $_ } $sec / 3600 % 24, $sec / 60 % 60, $sec % 60
: join q{:}, map { sprintf '%02d', $_ } $sec / 60 % 60, $sec % 60;
@@ -133,6 +136,8 @@ Return string "04 May 2010" from "2010-05-04T00:25:55.000Z"
sub format_date {
my ($self, $date) = @_;
+ $date // return undef;
+
# 2010-05-04T00:25:55.000Z
# to: 04 May 2010
@@ -158,6 +163,8 @@ Return the (approximated) age for a given date of the form "2010-05-04T00:25:55.
sub date_to_age {
my ($self, $date) = @_;
+ $date // return undef;
+
$date =~ m{^
(?<year>\d{4})
-
@@ -177,6 +184,21 @@ sub date_to_age {
$year += 1900;
$month += 1;
+ my %month_days = (
+ 1 => 31,
+ 2 => 28,
+ 3 => 31,
+ 4 => 30,
+ 5 => 31,
+ 6 => 30,
+ 7 => 31,
+ 8 => 31,
+ 9 => 30,
+ 10 => 31,
+ 11 => 30,
+ 12 => 31,
+ );
+
my $lambda = sub {
if ($year == $+{year}) {
@@ -192,6 +214,14 @@ sub date_to_age {
}
return join(' ', $day - $+{day}, 'days');
}
+
+ if ($month - $+{month} == 1) {
+ my $day_diff = $+{day} - $day;
+ if ($day_diff > 0 and $day_diff < $month_days{$+{month} + 0}) {
+ return join(' ', $month_days{$+{month} + 0} - $day_diff, 'days');
+ }
+ }
+
return join(' ', $month - $+{month}, 'months');
}
@@ -227,7 +257,7 @@ sub has_entries {
if (ref($result->{results}) eq 'HASH') {
- foreach my $type (qw(comments videos playlists)) {
+ foreach my $type (qw(comments videos playlists entries)) {
if (exists $result->{results}{$type}) {
ref($result->{results}{$type}) eq 'ARRAY' or return 0;
return (@{$result->{results}{$type}} > 0);
@@ -252,15 +282,21 @@ sub has_entries {
return 1; # maybe?
}
-=head2 normalize_video_title($title, $fat32safe)
+=head2 normalize_filename($title, $fat32safe)
Replace file-unsafe characters and trim spaces.
=cut
-sub normalize_video_title {
+sub normalize_filename {
my ($self, $title, $fat32safe) = @_;
+ state $unix_like = $^O =~ /^(?:linux|freebsd|openbsd)\z/i;
+
+ if (not $fat32safe and not $unix_like) {
+ $fat32safe = 1;
+ }
+
if ($fat32safe) {
$title =~ s/: / - /g;
$title =~ tr{:"*/?\\|}{;'+%!%%}; # "
@@ -270,7 +306,9 @@ sub normalize_video_title {
$title =~ tr{/}{%};
}
- join(q{ }, split(q{ }, $title));
+ my $basename = join(q{ }, split(q{ }, $title));
+ $basename = substr($basename, 0, 200); # make sure the filename is not too long
+ return $basename;
}
=head2 format_text(%opt)
@@ -299,20 +337,32 @@ sub format_text {
my $fat32safe = $opt{fat32safe};
my %special_tokens = (
- ID => sub { $self->get_video_id($info) },
- AUTHOR => sub { $self->get_channel_title($info) },
- CHANNELID => sub { $self->get_channel_id($info) },
- DEFINITION => sub { $self->get_definition($info) },
- DIMENSION => sub { $self->get_dimension($info) },
+ ID => sub { $self->get_video_id($info) },
+ AUTHOR => sub { $self->get_channel_title($info) },
+ CHANNELID => sub { $self->get_channel_id($info) },
+ DEFINITION => sub { $self->get_definition($info) },
+ DIMENSION => sub { $self->get_dimension($info) },
+
VIEWS => sub { $self->get_views($info) },
VIEWS_SHORT => sub { $self->get_views_approx($info) },
- LIKES => sub { $self->get_likes($info) },
- DISLIKES => sub { $self->get_dislikes($info) },
+
+ VIDEOS => sub { $self->set_thousands($self->get_channel_video_count($info)) },
+ VIDEOS_SHORT => sub { $self->short_human_number($self->get_channel_video_count($info)) },
+
+ SUBS => sub { $self->get_channel_subscriber_count($info) },
+ SUBS_SHORT => sub { $self->short_human_number($self->get_channel_subscriber_count($info)) },
+
+ ITEMS => sub { $self->set_thousands($self->get_playlist_item_count($info)) },
+ ITEMS_SHORT => sub { $self->short_human_number($self->get_playlist_item_count($info)) },
+
+ LIKES => sub { $self->get_likes($info) },
+ DISLIKES => sub { $self->get_dislikes($info) },
+
COMMENTS => sub { $self->get_comments($info) },
DURATION => sub { $self->get_duration($info) },
TIME => sub { $self->get_time($info) },
TITLE => sub { $self->get_title($info) },
- FTITLE => sub { $self->normalize_video_title($self->get_title($info), $fat32safe) },
+ FTITLE => sub { $self->normalize_filename($self->get_title($info), $fat32safe) },
CAPTION => sub { $self->get_caption($info) },
PUBLISHED => sub { $self->get_publication_date($info) },
AGE => sub { $self->get_publication_age($info) },
@@ -386,8 +436,8 @@ sub format_text {
$text =~ s/$escapes_re/$special_escapes{$1}/g;
$escape
- ? $text =~ s/$tokens_re/\Q${\$special_tokens{$1}()}\E/gr
- : $text =~ s/$tokens_re/${\$special_tokens{$1}()}/gr;
+ ? $text =~ s<$tokens_re><\Q${\($special_tokens{$1}() // '')}\E>gr
+ : $text =~ s<$tokens_re><${\($special_tokens{$1}() // '')}>gr;
}
=head2 set_thousands($num)
@@ -487,13 +537,112 @@ sub get_description {
$desc = HTML::Entities::decode_entities($desc);
$desc =~ s/^\s+//;
- if (not $desc =~ /\S/) {
+ if (not $desc =~ /\S/ or length($desc) < length($info->{description} // '')) {
$desc = $info->{description} // '';
}
($desc =~ /\S/) ? $desc : 'No description available...';
}
+sub read_lines_from_file {
+ my ($self, $file, $mode) = @_;
+
+ $mode //= '<';
+
+ open(my $fh, $mode, $file) or return;
+ chomp(my @lines = <$fh>);
+ close $fh;
+
+ my %seen;
+
+ # Keep the most recent ones
+ @lines = reverse(@lines);
+ @lines = grep { !$seen{$_}++ } @lines;
+
+ return @lines;
+}
+
+sub read_channels_from_file {
+ my ($self, $file, $mode) = @_;
+
+ $mode //= '<:utf8';
+
+ # Read channels and remove duplicates
+ my %channels = map { split(/ /, $_, 2) } $self->read_lines_from_file($file, $mode);
+
+ # Filter valid channels and pair with channel ID with title
+ my @channels = map { [$_, $channels{$_}] } grep { defined($channels{$_}) } keys %channels;
+
+ # Sort channels by channel name
+ @channels = sort { CORE::fc($a->[1]) cmp CORE::fc($b->[1]) } @channels;
+
+ return @channels;
+}
+
+sub get_local_playlist_filenames {
+ my ($self, $dir) = @_;
+ require Encode;
+ grep { -f $_ } sort { CORE::fc($a) cmp CORE::fc($b) } map { Encode::decode_utf8($_) } glob("$dir/*.dat");
+}
+
+sub make_local_playlist_filename {
+ my ($self, $title, $playlistID) = @_;
+ my $basename = $title . ' -- ' . $playlistID . '.txt';
+ $basename = $self->normalize_filename($basename);
+ return $basename;
+}
+
+sub local_playlist_snippet {
+ my ($self, $id) = @_;
+
+ require File::Basename;
+ my $title = File::Basename::basename($id);
+
+ $title =~ s/\.dat\z//;
+ $title =~ s/ -- PL[-\w]+\z//;
+ $title =~ s/_/ /g;
+ $title = ucfirst($title);
+
+ require Storable;
+ my $entries = eval { Storable::retrieve($id) } // [];
+
+ if (ref($entries) ne 'ARRAY') {
+ $entries = [];
+ }
+
+ my $video_count = 0;
+ my $video_id = undef;
+
+ if (@$entries) {
+ $video_id = $self->get_video_id($entries->[0]);
+ $video_count = scalar(@$entries);
+ }
+
+ scalar {
+ author => "local",
+ authorId => "local",
+ description => $title,
+ playlistId => $id,
+ playlistThumbnail => (defined($video_id) ? "https://i.ytimg.com/vi/$video_id/mqdefault.jpg" : undef),
+ title => $title,
+ type => "playlist",
+ videoCount => $video_count,
+ };
+}
+
+sub local_channel_snippet {
+ my ($self, $id, $title) = @_;
+
+ scalar {
+ author => $title,
+ authorId => $id,
+ type => "channel",
+ description => "<local channel>",
+ subCount => undef,
+ videoCount => undef,
+ };
+}
+
=head2 get_title($info)
Get title.
@@ -545,7 +694,7 @@ sub get_thumbnail_url {
$url = eval { $wanted[0]{url} } // return '';
}
else {
- warn "[!] Couldn't find thumbnail of type <<$type>>...";
+ ## warn "[!] Couldn't find thumbnail of type <<$type>>...";
$url = eval { $thumbs[0]{url} } // return '';
}
@@ -559,7 +708,7 @@ sub get_channel_title {
my ($self, $info) = @_;
#$info->{snippet}{channelTitle} || $self->get_channel_id($info);
- $info->{author};
+ $info->{author} // $info->{title};
}
sub get_author {
@@ -572,6 +721,31 @@ sub get_comment_id {
$info->{commentId};
}
+sub get_video_count {
+ my ($self, $info) = @_;
+ $info->{videoCount} // 0;
+}
+
+sub get_subscriber_count {
+ my ($self, $info) = @_;
+ $info->{subCount} // 0;
+}
+
+sub get_channel_subscriber_count {
+ my ($self, $info) = @_;
+ $info->{subCount} // 0;
+}
+
+sub get_channel_video_count {
+ my ($self, $info) = @_;
+ $info->{videoCount} // 0;
+}
+
+sub get_playlist_item_count {
+ my ($self, $info) = @_;
+ $info->{videoCount} // 0;
+}
+
sub get_comment_content {
my ($self, $info) = @_;
$info->{content};
@@ -579,24 +753,23 @@ sub get_comment_content {
sub get_id {
my ($self, $info) = @_;
-
- #$info->{id};
$info->{videoId};
}
-sub get_channel_id {
+sub get_rating {
my ($self, $info) = @_;
+ my $rating = $info->{rating} // return;
+ sprintf('%.2f', $rating);
+}
- #$info->{snippet}{resourceId}{channelId} // $info->{snippet}{channelId};
+sub get_channel_id {
+ my ($self, $info) = @_;
$info->{authorId};
}
sub get_category_id {
my ($self, $info) = @_;
-
- #$info->{snippet}{resourceId}{categoryId} // $info->{snippet}{categoryId};
- #"unknown";
- $info->{genre} // 'Unknown';
+ $info->{genre} // $info->{category} // 'Unknown';
}
sub get_category_name {
@@ -620,9 +793,7 @@ sub get_category_name {
29 => 'Nonprofits & Activism',
};
- #$categories->{$self->get_category_id($info) // ''} // 'Unknown';
-
- $info->{genre} // 'Unknown';
+ $info->{genre} // $info->{category} // 'Unknown';
}
sub get_publication_date {
@@ -635,8 +806,80 @@ sub get_publication_date {
require Encode;
require Time::Piece;
- my $time = Time::Piece->new($info->{published});
- Encode::decode_utf8($time->strftime("%d %B %Y"));
+ my $time;
+
+ if (defined($info->{published})) {
+ $time = eval { Time::Piece->new($info->{published}) };
+ }
+ elsif (defined($info->{publishDate})) {
+ if ($info->{publishDate} =~ /^[0-9]+\z/) { # time given as "%yyyy%mm%dd" (from hypervideo)
+ $time = eval { Time::Piece->strptime($info->{publishDate}, '%Y%m%d') };
+ }
+ else {
+ $time = eval { Time::Piece->strptime($info->{publishDate}, '%Y-%m-%d') };
+ }
+ }
+
+ defined($time) ? Encode::decode_utf8($time->strftime("%d %B %Y")) : undef;
+}
+
+sub get_publication_time {
+ my ($self, $info) = @_;
+
+ require Time::Piece;
+ require Time::Seconds;
+
+ if ($self->get_time($info) eq 'LIVE') {
+ my $time = $info->{timestamp} // Time::Piece->new();
+
+ if (ref($time) eq 'ARRAY') {
+ $time = bless($time, "Time::Piece");
+ }
+
+ return $time;
+ }
+
+ if (defined($info->{publishedText})) {
+
+ my $age = $info->{publishedText};
+ my $t = $info->{timestamp} // Time::Piece->new();
+
+ if (ref($t) eq 'ARRAY') {
+ $t = bless($t, "Time::Piece");
+ }
+
+ if ($age =~ /^(\d+) sec/) {
+ $t -= $1;
+ }
+
+ if ($age =~ /^(\d+) min/) {
+ $t -= $1 * Time::Seconds::ONE_MINUTE();
+ }
+
+ if ($age =~ /^(\d+) hour/) {
+ $t -= $1 * Time::Seconds::ONE_HOUR();
+ }
+
+ if ($age =~ /^(\d+) day/) {
+ $t -= $1 * Time::Seconds::ONE_DAY();
+ }
+
+ if ($age =~ /^(\d+) week/) {
+ $t -= $1 * Time::Seconds::ONE_WEEK();
+ }
+
+ if ($age =~ /^(\d+) month/) {
+ $t -= $1 * Time::Seconds::ONE_MONTH();
+ }
+
+ if ($age =~ /^(\d+) year/) {
+ $t -= $1 * Time::Seconds::ONE_YEAR();
+ }
+
+ return $t;
+ }
+
+ return $self->get_publication_date($info); # should not happen
}
sub get_publication_age {
@@ -674,22 +917,17 @@ sub get_publication_age_approx {
sub get_duration {
my ($self, $info) = @_;
-
- #$self->format_duration($info->{contentDetails}{duration});
- #$self->format_duration($info->{lengthSeconds});
$info->{lengthSeconds};
}
sub get_time {
my ($self, $info) = @_;
- if ($info->{liveNow}) {
+ if ($info->{liveNow} and ($self->get_duration($info) || 0) == 0) {
return 'LIVE';
}
$self->format_time($self->get_duration($info));
-
- #$self->format_time($self->get_duration($info));
}
sub get_definition {
@@ -721,39 +959,44 @@ sub get_views {
$info->{viewCount} // 0;
}
-sub get_views_approx {
- my ($self, $info) = @_;
- my $views = $self->get_views($info);
+sub short_human_number {
+ my ($self, $int) = @_;
- if ($views < 1000) {
- return $views;
+ if ($int < 1000) {
+ return $int;
}
- if ($views >= 10 * 1e9) { # ten billions
- return sprintf("%dB", int($views / 1e9));
+ if ($int >= 10 * 1e9) { # ten billions
+ return sprintf("%dB", int($int / 1e9));
}
- if ($views >= 1e9) { # billions
- return sprintf("%.2gB", $views / 1e9);
+ if ($int >= 1e9) { # billions
+ return sprintf("%.2gB", $int / 1e9);
}
- if ($views >= 10 * 1e6) { # ten millions
- return sprintf("%dM", int($views / 1e6));
+ if ($int >= 10 * 1e6) { # ten millions
+ return sprintf("%dM", int($int / 1e6));
}
- if ($views >= 1e6) { # millions
- return sprintf("%.2gM", $views / 1e6);
+ if ($int >= 1e6) { # millions
+ return sprintf("%.2gM", $int / 1e6);
}
- if ($views >= 10 * 1e3) { # ten thousands
- return sprintf("%dK", int($views / 1e3));
+ if ($int >= 10 * 1e3) { # ten thousands
+ return sprintf("%dK", int($int / 1e3));
}
- if ($views >= 1e3) { # thousands
- return sprintf("%.2gK", $views / 1e3);
+ if ($int >= 1e3) { # thousands
+ return sprintf("%.2gK", $int / 1e3);
}
- return $views;
+ return $int;
+}
+
+sub get_views_approx {
+ my ($self, $info) = @_;
+ my $views = $self->get_views($info);
+ $self->short_human_number($views);
}
sub get_likes {
diff --git a/lib/WWW/FairViewer/Videos.pm b/lib/WWW/FairViewer/Videos.pm
index 4acd866..72ad523 100644
--- a/lib/WWW/FairViewer/Videos.pm
+++ b/lib/WWW/FairViewer/Videos.pm
@@ -149,7 +149,7 @@ When C<$part> is C<undef>, it defaults to I<snippet>.
=cut
-sub video_details {
+sub _invidious_video_details {
my ($self, $id, $fields) = @_;
$fields //= $self->basic_video_info_fields;
@@ -159,24 +159,92 @@ sub video_details {
return $info;
}
+ return;
+}
+
+sub _ytdl_video_details {
+ my ($self, $id) = @_;
+ $self->_info_from_ytdl($id);
+}
+
+sub _fallback_video_details {
+ my ($self, $id, $fields) = @_;
+
+ if ($self->get_debug) {
+ say STDERR ":: Extracting video info with hypervideo...";
+ }
+
+ my $info = $self->_ytdl_video_details($id);
+
+ if (defined($info) and ref($info) eq 'HASH') {
+ return scalar {
+
+ title => $info->{fulltitle} // $info->{title},
+ videoId => $id,
+
+ videoThumbnails => [
+ map {
+ scalar {
+ quality => 'medium',
+ url => $_->{url},
+ width => $_->{width},
+ height => $_->{height},
+ }
+ } @{$info->{thumbnails}}
+ ],
+
+ liveNow => ($info->{is_live} ? 1 : 0),
+ description => $info->{description},
+ lengthSeconds => $info->{duration},
+
+ likeCount => $info->{like_count},
+ dislikeCount => $info->{dislike_count},
+
+ category => eval { $info->{categories}[0] } // $info->{category},
+ publishDate => $info->{upload_date},
+
+ keywords => $info->{tags},
+ viewCount => $info->{view_count},
+
+ author => $info->{channel},
+ authorId => $info->{channel_id} // $info->{uploader_id},
+ rating => $info->{average_rating},
+ };
+ }
+ else {
+ #$info = $self->_invidious_video_details($id, $fields); # too slow
+ }
+
+ return {};
+}
+
+sub video_details {
+ my ($self, $id, $fields) = @_;
+
if ($self->get_debug) {
say STDERR ":: Extracting video info using the fallback method...";
}
- # Fallback using the `get_video_info` URL
my %video_info = $self->_get_video_info($id);
- my $video = $self->parse_json_string($video_info{player_response} // return);
+ my $video = $self->parse_json_string($video_info{player_response} // return $self->_fallback_video_details($id, $fields));
+
+ my $videoDetails = {};
+ my $microformat = {};
if (exists $video->{videoDetails}) {
- $video = $video->{videoDetails};
+ $videoDetails = $video->{videoDetails};
}
else {
- return;
+ return $self->_fallback_video_details($id, $fields);
+ }
+
+ if (exists $video->{microformat}) {
+ $microformat = eval { $video->{microformat}{playerMicroformatRenderer} } // {};
}
my %details = (
- title => $video->{title},
- videoId => $video->{videoId},
+ title => eval { $microformat->{title}{simpleText} } // $videoDetails->{title},
+ videoId => $videoDetails->{videoId},
videoThumbnails => [
map {
@@ -186,19 +254,22 @@ sub video_details {
width => $_->{width},
height => $_->{height},
}
- } @{$video->{thumbnail}{thumbnails}}
+ } @{$videoDetails->{thumbnail}{thumbnails}}
],
- liveNow => $video->{isLiveContent},
- description => $video->{shortDescription},
- lengthSeconds => $video->{lengthSeconds},
+ liveNow => ($videoDetails->{isLiveContent} || (($videoDetails->{lengthSeconds} || 0) == 0)),
+ description => eval { $microformat->{description}{simpleText} } // $videoDetails->{shortDescription},
+ lengthSeconds => $videoDetails->{lengthSeconds} // $microformat->{lengthSeconds},
- keywords => $video->{keywords},
- viewCount => $video->{viewCount},
+ category => $microformat->{category},
+ publishDate => $microformat->{publishDate},
- author => $video->{author},
- authorId => $video->{channelId},
- rating => $video->{averageRating},
+ keywords => $videoDetails->{keywords},
+ viewCount => $videoDetails->{viewCount} // $microformat->{viewCount},
+
+ author => $videoDetails->{author} // $microformat->{ownerChannelName},
+ authorId => $videoDetails->{channelId} // $microformat->{externalChannelId},
+ rating => $videoDetails->{averageRating},
);
return \%details;
@@ -218,6 +289,8 @@ with a HASH ref for each result. An example of the item array's content are show
=head1 AUTHOR
+Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
+
Jesus, C<< <echo aGVja3llbEBoeXBlcmJvbGEuaW5mbw== | base64 -d> >>