YoutubeViewer.pm 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504
  1. package WWW::YoutubeViewer;
  2. use utf8;
  3. use 5.016;
  4. use warnings;
  5. use Memoize qw(memoize);
  6. #<<<
  7. #~ use Memoize::Expire;
  8. #~ tie my %youtubei_cache => 'Memoize::Expire',
  9. #~ LIFETIME => 600, # in seconds
  10. #~ NUM_USES => 2;
  11. #~ memoize '_get_youtubei_content', SCALAR_CACHE => [HASH => \%youtubei_cache];
  12. #>>>
  13. #memoize('_get_video_info');
  14. memoize('_ytdl_is_available');
  15. memoize('_info_from_ytdl');
  16. #memoize('_extract_from_ytdl');
  17. memoize('_extract_from_invidious');
  18. use parent qw(
  19. WWW::YoutubeViewer::Search
  20. WWW::YoutubeViewer::Videos
  21. WWW::YoutubeViewer::Channels
  22. WWW::YoutubeViewer::Playlists
  23. WWW::YoutubeViewer::ParseJSON
  24. WWW::YoutubeViewer::Activities
  25. WWW::YoutubeViewer::Subscriptions
  26. WWW::YoutubeViewer::PlaylistItems
  27. WWW::YoutubeViewer::CommentThreads
  28. WWW::YoutubeViewer::Authentication
  29. WWW::YoutubeViewer::VideoCategories
  30. );
  31. use WWW::YoutubeViewer::Utils;
  32. =head1 NAME
  33. WWW::YoutubeViewer - A very easy interface to YouTube.
  34. =cut
  35. our $VERSION = '3.11.3';
  36. =head1 SYNOPSIS
  37. use WWW::YoutubeViewer;
  38. my $yv_obj = WWW::YoutubeViewer->new();
  39. ...
  40. =head1 SUBROUTINES/METHODS
  41. =cut
  42. my %valid_options = (
  43. # Main options
  44. v => {valid => q[], default => 3},
  45. page => {valid => qr/^(?!0+\z)\d+\z/, default => 1},
  46. http_proxy => {valid => qr/./, default => undef},
  47. hl => {valid => qr/^\w+(?:[\-_]\w+)?\z/, default => undef},
  48. maxResults => {valid => [1 .. 50], default => 10},
  49. topicId => {valid => qr/./, default => undef},
  50. order => {valid => [qw(relevance date rating viewCount title videoCount)], default => undef},
  51. publishedAfter => {valid => qr/^\d+/, default => undef},
  52. publishedBefore => {valid => qr/^\d+/, default => undef},
  53. channelId => {valid => qr/^[-\w]{2,}\z/, default => undef},
  54. channelType => {valid => [qw(any show)], default => undef},
  55. # Video only options
  56. videoCaption => {valid => [qw(closedCaption none)], default => undef},
  57. videoDefinition => {valid => [qw(high standard)], default => undef},
  58. videoCategoryId => {valid => qr/^\d+\z/, default => undef},
  59. videoDimension => {valid => [qw(2d 3d)], default => undef},
  60. videoDuration => {valid => [qw(short medium long)], default => undef},
  61. videoEmbeddable => {valid => [qw(true)], default => undef},
  62. videoLicense => {valid => [qw(creativeCommon youtube)], default => undef},
  63. videoSyndicated => {valid => [qw(true)], default => undef},
  64. eventType => {valid => [qw(completed live upcoming)], default => undef},
  65. chart => {valid => [qw(mostPopular)], default => undef},
  66. regionCode => {valid => qr/^[A-Z]{2}\z/i, default => undef},
  67. relevanceLanguage => {valid => qr/^[a-z]+(?:\-\w+)?\z/i, default => undef},
  68. safeSearch => {valid => [qw(none moderate strict)], default => undef},
  69. videoType => {valid => [qw(episode movie)], default => undef},
  70. comments_order => {valid => [qw(time relevance)], default => 'time'},
  71. subscriptions_order => {valid => [qw(alphabetical relevance unread)], default => undef},
  72. # Misc
  73. debug => {valid => [0 .. 3], default => 0},
  74. timeout => {valid => qr/^\d+\z/, default => 10},
  75. config_dir => {valid => qr/^./, default => q{.}},
  76. cache_dir => {valid => qr/^./, default => q{.}},
  77. cookie_file => {valid => qr/^./, default => undef},
  78. # Support for yt-dlp / youtube-dl
  79. ytdl => {valid => [1, 0], default => 1},
  80. ytdl_cmd => {valid => qr/\w/, default => "yt-dlp"},
  81. # Booleans
  82. env_proxy => {valid => [1, 0], default => 1},
  83. escape_utf8 => {valid => [1, 0], default => 0},
  84. prefer_mp4 => {valid => [1, 0], default => 0},
  85. prefer_av1 => {valid => [1, 0], default => 0},
  86. force_fallback => {valid => [1, 0], default => 0},
  87. bypass_age_gate_native => {valid => [1, 0], default => 0},
  88. bypass_age_gate_with_proxy => {valid => [1, 0], default => 0},
  89. # API/OAuth
  90. key => {valid => qr/^.{15}/, default => undef},
  91. client_id => {valid => qr/^.{15}/, default => undef},
  92. client_secret => {valid => qr/^.{15}/, default => undef},
  93. redirect_uri => {valid => qr/^.{15}/, default => 'urn:ietf:wg:oauth:2.0:oob'},
  94. tv_grant_type => {valid => qr/^.{15}/, default => 'urn:ietf:params:oauth:grant-type:device_code'},
  95. access_token => {valid => qr/^.{15}/, default => undef},
  96. refresh_token => {valid => qr/^.{15}/, default => undef},
  97. authentication_file => {valid => qr/^./, default => undef},
  98. #<<<
  99. # No input value allowed
  100. feeds_url => {valid => q[], default => 'https://www.googleapis.com/youtube/v3/'},
  101. video_info_url => {valid => q[], default => 'https://www.youtube.com/get_video_info'},
  102. #oauth_url => {valid => q[], default => 'https://accounts.google.com/o/oauth2/'},
  103. oauth_url => {valid => q[], default => 'https://oauth2.googleapis.com'},
  104. www_content_type => {valid => q[], default => 'application/x-www-form-urlencoded'},
  105. youtubei_url => {valid => q[], default => 'https://youtubei.googleapis.com/youtubei/v1/%s?key=' . reverse("8Wcq11_9Y_wliCGLHETS4Q8UqlS2JF_OAySazIA")},
  106. #>>>
  107. #<<<
  108. # LWP user agent
  109. user_agent => {valid => qr/^.{5}/, default => 'Mozilla/5.0 (Android 14; Tablet; rv:109.0) Gecko/122.0 Firefox/122.0,gzip(gfe)'},
  110. #>>>
  111. );
  112. sub _our_smartmatch {
  113. my ($value, $arg) = @_;
  114. $value // return 0;
  115. if (not ref($arg)) {
  116. return ($value eq $arg);
  117. }
  118. if (ref($arg) eq ref(qr//)) {
  119. return scalar($value =~ $arg);
  120. }
  121. if (ref($arg) eq 'ARRAY') {
  122. foreach my $item (@$arg) {
  123. return 1 if __SUB__->($value, $item);
  124. }
  125. }
  126. return 0;
  127. }
  128. {
  129. no strict 'refs';
  130. foreach my $key (keys %valid_options) {
  131. if (ref($valid_options{$key}{valid})) {
  132. # Create the 'set_*' subroutines
  133. *{__PACKAGE__ . '::set_' . $key} = sub {
  134. my ($self, $value) = @_;
  135. $self->{$key} =
  136. _our_smartmatch($value, $valid_options{$key}{valid})
  137. ? $value
  138. : $valid_options{$key}{default};
  139. };
  140. }
  141. # Create the 'get_*' subroutines
  142. *{__PACKAGE__ . '::get_' . $key} = sub {
  143. my ($self) = @_;
  144. if (not exists $self->{$key}) {
  145. return ($self->{$key} = $valid_options{$key}{default});
  146. }
  147. $self->{$key};
  148. };
  149. }
  150. }
  151. =head2 new(%opts)
  152. Returns a blessed object.
  153. =cut
  154. sub new {
  155. my ($class, %opts) = @_;
  156. my $self = bless {}, $class;
  157. foreach my $key (keys %valid_options) {
  158. if (exists $opts{$key}) {
  159. my $method = "set_$key";
  160. $self->$method(delete $opts{$key});
  161. }
  162. }
  163. foreach my $invalid_key (keys %opts) {
  164. warn "Invalid key: '${invalid_key}'";
  165. }
  166. return $self;
  167. }
  168. sub page_token {
  169. my ($self, $number) = @_;
  170. my $page = $number // $self->get_page;
  171. # Don't generate the token for the first page
  172. return undef if $page == 1;
  173. my $index = $page * $self->get_maxResults() - $self->get_maxResults();
  174. my $k = int($index / 128) - 1;
  175. $index -= 128 * $k;
  176. my @f = (8, $index);
  177. if ($k > 0 or $index > 127) {
  178. push @f, $k + 1;
  179. }
  180. require MIME::Base64;
  181. MIME::Base64::encode_base64(pack('C*', @f, 16, 0)) =~ tr/=\n//dr;
  182. }
  183. =head2 escape_string($string)
  184. Escapes a string with URI::Escape and returns it.
  185. =cut
  186. sub escape_string {
  187. my ($self, $string) = @_;
  188. require URI::Escape;
  189. $self->get_escape_utf8
  190. ? URI::Escape::uri_escape_utf8($string)
  191. : URI::Escape::uri_escape($string);
  192. }
  193. =head2 set_lwp_useragent()
  194. Initializes the LWP::UserAgent module and returns it.
  195. =cut
  196. sub set_lwp_useragent {
  197. my ($self) = @_;
  198. my $lwp = (
  199. eval { require LWP::UserAgent::Cached; 'LWP::UserAgent::Cached' }
  200. // do { require LWP::UserAgent; 'LWP::UserAgent' }
  201. );
  202. my $agent = $lwp->new(
  203. cookie_jar => {}, # temporary cookies
  204. timeout => $self->get_timeout,
  205. show_progress => $self->get_debug,
  206. agent => $self->get_user_agent,
  207. ssl_opts => {verify_hostname => 1},
  208. $lwp eq 'LWP::UserAgent::Cached'
  209. ? (
  210. cache_dir => $self->get_cache_dir,
  211. nocache_if => sub {
  212. my ($response) = @_;
  213. my $code = $response->code;
  214. $code >= 300 # do not cache any bad response
  215. or $response->request->method ne 'GET' # cache only GET requests
  216. # don't cache if "cache-control" specifies "max-age=0", "no-store" or "no-cache"
  217. or (($response->header('cache-control') // '') =~ /\b(?:max-age=0|no-store|no-cache)\b/)
  218. # don't cache video or audio files
  219. or (($response->header('content-type') // '') =~ /\b(?:video|audio)\b/);
  220. },
  221. recache_if => sub {
  222. my ($response, $path) = @_;
  223. not($response->is_fresh) # recache if the response expired
  224. or ($response->code == 404 && -M $path > 1); # recache any 404 response older than 1 day
  225. }
  226. )
  227. : (),
  228. env_proxy => (defined($self->get_http_proxy) ? 0 : $self->get_env_proxy),
  229. );
  230. require LWP::ConnCache;
  231. state $cache = LWP::ConnCache->new;
  232. $cache->total_capacity(undef); # no limit
  233. state $accepted_encodings = do {
  234. require HTTP::Message;
  235. HTTP::Message::decodable();
  236. };
  237. $agent->ssl_opts(Timeout => $self->get_timeout);
  238. $agent->default_header(
  239. 'Accept-Encoding' => $accepted_encodings,
  240. 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
  241. 'Accept-Language' => 'en-US,en;q=0.5',
  242. 'Connection' => 'keep-alive',
  243. 'Upgrade-Insecure-Requests' => '1',
  244. );
  245. $agent->conn_cache($cache);
  246. $agent->proxy(['http', 'https'], $self->get_http_proxy) if defined($self->get_http_proxy);
  247. my $cookie_file = $self->get_cookie_file;
  248. if (defined($cookie_file) and -f $cookie_file) {
  249. if ($self->get_debug) {
  250. say STDERR ":: Using cookies from: $cookie_file";
  251. }
  252. ## Netscape HTTP Cookies
  253. # Firefox extension:
  254. # https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/
  255. # See also:
  256. # https://github.com/ytdl-org/youtube-dl#how-do-i-pass-cookies-to-youtube-dl
  257. require HTTP::Cookies::Netscape;
  258. my $cookies = HTTP::Cookies::Netscape->new(
  259. hide_cookie2 => 1,
  260. autosave => 1,
  261. file => $cookie_file,
  262. );
  263. $cookies->load;
  264. $agent->cookie_jar($cookies);
  265. }
  266. push @{$agent->requests_redirectable}, 'POST';
  267. $self->{lwp} = $agent;
  268. return $agent;
  269. }
  270. =head2 prepare_access_token()
  271. Returns a string. used as header, with the access token.
  272. =cut
  273. sub prepare_access_token {
  274. my ($self) = @_;
  275. if (defined(my $auth = $self->get_access_token)) {
  276. return "Bearer $auth";
  277. }
  278. return;
  279. }
  280. sub _auth_lwp_header {
  281. my ($self) = @_;
  282. my %lwp_header;
  283. if (defined $self->get_access_token) {
  284. $lwp_header{'Authorization'} = $self->prepare_access_token;
  285. }
  286. return %lwp_header;
  287. }
  288. sub _warn_response_error {
  289. my ($resp, $url) = @_;
  290. warn sprintf("[%s] Error occurred on URL: %s\n", $resp->status_line, $url =~ s/([&?])key=(.*?)&/${1}key=[...]&/r);
  291. }
  292. sub _request_with_authorization {
  293. my ($self, $block, %opt) = @_;
  294. my $response = $opt{simple} ? $block->() : $block->($self->_auth_lwp_header);
  295. if ($response->is_success or $opt{simple}) {
  296. return $response;
  297. }
  298. if ($response->status_line() =~ /^401 / and defined($self->get_refresh_token)) {
  299. if (defined(my $refresh_token = $self->oauth_refresh_token())) {
  300. if (defined $refresh_token->{access_token}) {
  301. $self->set_access_token($refresh_token->{access_token});
  302. # Don't be tempted to use recursion here, because bad things will happen!
  303. $response = $block->($self->_auth_lwp_header);
  304. if ($response->is_success) {
  305. $self->save_authentication_tokens();
  306. return $response;
  307. }
  308. if ($response->status_line() =~ /^401 /) {
  309. $self->set_refresh_token(); # refresh token was invalid
  310. $self->set_access_token(); # access token is also broken
  311. warn "[!] Can't refresh the access token! Logging out...\n";
  312. }
  313. }
  314. else {
  315. warn "[!] Can't get the access_token! Logging out...\n";
  316. $self->set_refresh_token();
  317. $self->set_access_token();
  318. }
  319. }
  320. else {
  321. warn "[!] Invalid refresh_token! Logging out...\n";
  322. $self->set_refresh_token();
  323. $self->set_access_token();
  324. }
  325. }
  326. return $response;
  327. }
  328. =head2 lwp_get($url, %opt)
  329. Get and return the content for $url.
  330. Where %opt can be:
  331. simple => [bool]
  332. When the value of B<simple> is set to a true value, the
  333. authentication header will not be set in the HTTP request.
  334. =cut
  335. sub lwp_get {
  336. my ($self, $url, %opt) = @_;
  337. $url || return;
  338. $self->{lwp} // $self->set_lwp_useragent();
  339. state @LWP_CACHE;
  340. if (not defined($self->get_key)) {
  341. return undef if not $opt{simple};
  342. }
  343. # Check the cache
  344. foreach my $entry (@LWP_CACHE) {
  345. if ($entry->{url} eq $url and time - $entry->{timestamp} <= 600) {
  346. return $entry->{content};
  347. }
  348. }
  349. my $response = $self->_request_with_authorization(sub { $self->{lwp}->get($url, @_) }, %opt);
  350. if ($response->is_success) {
  351. my $content = $response->decoded_content;
  352. unshift(@LWP_CACHE, {url => $url, content => $content, timestamp => time});
  353. pop(@LWP_CACHE) if (scalar(@LWP_CACHE) >= 50);
  354. return $content;
  355. }
  356. $opt{depth} ||= 0;
  357. # Try again on 500+ HTTP errors
  358. if ( $opt{depth} <= 3
  359. and $response->code() >= 500
  360. and $response->status_line() =~ /(?:Temporary|Server) Error|Timeout|Service Unavailable/i) {
  361. return $self->lwp_get($url, %opt, depth => $opt{depth} + 1);
  362. }
  363. _warn_response_error($response, $url);
  364. return;
  365. }
  366. =head2 lwp_post($url, [@args])
  367. Post and return the content for $url.
  368. =cut
  369. sub lwp_post {
  370. my ($self, $url, %opt) = @_;
  371. $self->{lwp} // $self->set_lwp_useragent();
  372. my $response = $self->_request_with_authorization(
  373. sub {
  374. $self->{lwp}->post($url, (exists($opt{headers}) ? $opt{headers} : ()), @_);
  375. },
  376. %opt
  377. );
  378. if ($response->is_success) {
  379. return $response->decoded_content;
  380. }
  381. _warn_response_error($response, $url);
  382. return;
  383. }
  384. =head2 lwp_mirror($url, $output_file)
  385. Downloads the $url into $output_file. Returns true on success.
  386. =cut
  387. sub lwp_mirror {
  388. my ($self, $url, $output_file) = @_;
  389. $self->{lwp} // $self->set_lwp_useragent();
  390. $self->{lwp}->mirror($url, $output_file);
  391. }
  392. sub _send_request {
  393. my ($self, $method, $url, $content) = @_;
  394. $self->{lwp} // $self->set_lwp_useragent();
  395. my $response = $self->_request_with_authorization(
  396. sub {
  397. require HTTP::Request;
  398. my $req = HTTP::Request->new($method => $url);
  399. $req->header('Authorization' => $self->prepare_access_token) if defined($self->get_access_token);
  400. if (defined($content)) {
  401. $req->content_type('application/json; charset=UTF-8');
  402. $req->header('Content-Length' => length($content));
  403. $req->content($content);
  404. }
  405. $self->{lwp}->request($req);
  406. },
  407. );
  408. if ($response->is_success) {
  409. return $response->decoded_content;
  410. }
  411. _warn_response_error($response, $url);
  412. return;
  413. }
  414. =head2 post_as_json($url, $ref)
  415. Send a C<POST> request to the given URL, with JSON content given as a Perl REF structure. Returns the response content.
  416. =cut
  417. sub post_as_json {
  418. my ($self, $url, $ref) = @_;
  419. my $json_str = $self->make_json_string($ref);
  420. $self->_send_request('POST', $url, $json_str);
  421. }
  422. =head2 lwp_delete($url)
  423. Send a C<DELETE> request to the given URL. Returns the response content.
  424. =cut
  425. sub lwp_delete {
  426. my ($self, $url) = @_;
  427. $self->_send_request('DELETE', $url);
  428. }
  429. sub _get_results {
  430. my ($self, $url, %opt) = @_;
  431. return
  432. scalar {
  433. url => $url,
  434. results => $self->parse_json_string($self->lwp_get($url, %opt)),
  435. };
  436. }
  437. =head2 list_to_url_arguments(\%options)
  438. Returns a valid string of arguments, with defined values.
  439. =cut
  440. sub list_to_url_arguments {
  441. my ($self, %args) = @_;
  442. join(q{&}, map { "$_=$args{$_}" } grep { defined $args{$_} } sort keys %args);
  443. }
  444. sub _append_url_args {
  445. my ($self, $url, %args) = @_;
  446. %args
  447. ? ($url . ($url =~ /\?/ ? '&' : '?') . $self->list_to_url_arguments(%args))
  448. : $url;
  449. }
  450. sub _simple_feeds_url {
  451. my ($self, $suburl, %args) = @_;
  452. $self->get_feeds_url() . $suburl . '?' . $self->list_to_url_arguments(key => $self->get_key, %args);
  453. }
  454. =head2 default_arguments(%args)
  455. Merge the default arguments with %args and concatenate them together.
  456. =cut
  457. sub default_arguments {
  458. my ($self, %args) = @_;
  459. my %defaults = (
  460. key => $self->get_key,
  461. part => 'snippet',
  462. prettyPrint => 'false',
  463. maxResults => $self->get_maxResults,
  464. regionCode => $self->get_regionCode,
  465. %args,
  466. );
  467. $self->list_to_url_arguments(%defaults);
  468. }
  469. sub _make_feed_url {
  470. my ($self, $path, %args) = @_;
  471. $self->get_feeds_url() . $path . '?' . $self->default_arguments(%args);
  472. }
  473. sub get_invidious_instances {
  474. my ($self) = @_;
  475. require File::Spec;
  476. my $instances_file = File::Spec->catfile($self->get_config_dir, 'instances.json');
  477. # Get the "instances.json" file when the local copy is too old or non-existent
  478. if ((not -e $instances_file) or (-M _) > 1 / 24) {
  479. require LWP::UserAgent;
  480. my $lwp = LWP::UserAgent->new(timeout => $self->get_timeout);
  481. $lwp->show_progress(1) if $self->get_debug;
  482. my $resp = $lwp->get("https://api.invidious.io/instances.json");
  483. $resp->is_success() or return;
  484. my $json = $resp->decoded_content() || return;
  485. open(my $fh, '>', $instances_file) or return;
  486. print $fh $json;
  487. close $fh;
  488. }
  489. open(my $fh, '<', $instances_file) or return;
  490. my $json_string = do {
  491. local $/;
  492. <$fh>;
  493. };
  494. $self->parse_json_string($json_string);
  495. }
  496. sub select_good_invidious_instances {
  497. my ($self, %args) = @_;
  498. state $instances = $self->get_invidious_instances;
  499. ref($instances) eq 'ARRAY' or return;
  500. my %ignored = (
  501. 'yewtu.be' => 1, # 403 Forbidden (API)
  502. 'invidious.tube' => 1, # down?
  503. 'invidiou.site' => 0,
  504. 'invidious.site' => 1, # AGPL Violation + trackers
  505. 'invidious.zee.li' => 1, # uses Cloudflare // 500 read timeout
  506. 'invidious.048596.xyz' => 1, # broken API
  507. 'invidious.xyz' => 1, # 502 Bad Gateway
  508. 'vid.mint.lgbt' => 0,
  509. 'invidious.ggc-project.de' => 1, # broken API
  510. 'invidious.toot.koeln' => 1, # broken API
  511. 'invidious.kavin.rocks' => 1, # 403 Forbidden (API)
  512. 'invidious.snopyta.org' => 1, # dead (15 January 2024)
  513. 'invidious.silkky.cloud' => 0,
  514. 'invidious.moomoo.me' => 1, # uses Cloudflare
  515. 'y.com.cm' => 1, # uses Cloudflare
  516. 'invidious.exonip.de' => 1, # 403 Forbidden (API)
  517. 'invidious-us.kavin.rocks' => 1, # 403 Forbidden (API)
  518. 'invidious-jp.kavin.rocks' => 1, # 403 Forbidden (API)
  519. );
  520. #<<<
  521. my @candidates =
  522. grep { not $ignored{$_->[0]} }
  523. grep { $args{lax} ? 1 : eval { lc($_->[1]{monitor}{dailyRatios}[0]{label} // '') eq 'success' } }
  524. #~ grep { $args{lax} ? 1 : eval { lc($_->[1]{monitor}{weeklyRatio}{label} // '') eq 'success' } }
  525. grep { $args{lax} ? 1 : eval { lc($_->[1]{monitor}{statusClass} // '') eq 'success' } }
  526. #~ grep { $args{lax} ? 1 : !exists($_->[1]{stats}{error}) }
  527. grep { lc($_->[1]{type} // '') eq 'https' } @$instances;
  528. #>>>
  529. if ($self->get_debug) {
  530. my @hosts = map { $_->[0] } @candidates;
  531. my $count = scalar(@candidates);
  532. print STDERR ":: Found $count invidious instances: @hosts\n";
  533. }
  534. return @candidates;
  535. }
  536. sub _extract_from_invidious {
  537. my ($self, $videoID) = @_;
  538. my @candidates = $self->select_good_invidious_instances();
  539. my @extra_candidates = $self->select_good_invidious_instances(lax => 1);
  540. require List::Util;
  541. #<<<
  542. my %seen;
  543. my @instances = grep { !$seen{$_}++ } (
  544. List::Util::shuffle(map { $_->[0] } @candidates),
  545. List::Util::shuffle(map { $_->[0] } @extra_candidates),
  546. );
  547. #>>>
  548. if (@instances) {
  549. push @instances, 'invidious.fdn.fr';
  550. }
  551. else {
  552. @instances = qw(
  553. invidious.fdn.fr
  554. vid.puffyan.us
  555. invidious.privacydev.net
  556. invidious.flokinet.to
  557. );
  558. }
  559. if ($self->get_debug) {
  560. print STDERR ":: Invidious instances: @instances\n";
  561. }
  562. # Restrict the number of invidious instances to the first 5.
  563. # If the first 5 instances fail, most likely all will fail.
  564. if (scalar(@instances) > 5) {
  565. $#instances = 4;
  566. }
  567. my $tries = 2 * scalar(@instances);
  568. my $instance = shift(@instances);
  569. my $url_format = "https://%s/api/v1/videos/%s?fields=formatStreams,adaptiveFormats";
  570. my $url = sprintf($url_format, $instance, $videoID);
  571. my $resp = $self->{lwp}->get($url);
  572. while (not $resp->is_success() and --$tries >= 0) {
  573. $url = sprintf($url_format, shift(@instances), $videoID) if (@instances and ($tries % 2 == 0));
  574. $resp = $self->{lwp}->get($url);
  575. }
  576. $resp->is_success() || return;
  577. my $json = $resp->decoded_content() // return;
  578. my $ref = $self->parse_json_string($json) // return;
  579. my @formats;
  580. # The entries are already in the format that we want.
  581. if (exists($ref->{adaptiveFormats}) and ref($ref->{adaptiveFormats}) eq 'ARRAY') {
  582. push @formats, @{$ref->{adaptiveFormats}};
  583. }
  584. if (exists($ref->{formatStreams}) and ref($ref->{formatStreams}) eq 'ARRAY') {
  585. push @formats, @{$ref->{formatStreams}};
  586. }
  587. return @formats;
  588. }
  589. sub _ytdl_is_available {
  590. my ($self) = @_;
  591. ($self->proxy_stdout($self->get_ytdl_cmd(), '--version') // '') =~ /\d/;
  592. }
  593. sub _info_from_ytdl {
  594. my ($self, $videoID) = @_;
  595. $self->_ytdl_is_available() || return;
  596. my @ytdl_cmd = ($self->get_ytdl_cmd(), '--all-formats', '--dump-single-json');
  597. my $cookie_file = $self->get_cookie_file;
  598. if (defined($cookie_file) and -f $cookie_file) {
  599. push @ytdl_cmd, '--cookies', quotemeta($cookie_file);
  600. }
  601. my $json = $self->proxy_stdout(@ytdl_cmd, quotemeta("https://www.youtube.com/watch?v=" . $videoID));
  602. my $ref = $self->parse_json_string($json // return);
  603. if ($self->get_debug >= 3) {
  604. require Data::Dump;
  605. Data::Dump::pp($ref);
  606. }
  607. return $ref;
  608. }
  609. sub _extract_from_ytdl {
  610. my ($self, $videoID) = @_;
  611. my $ref = $self->_info_from_ytdl($videoID) // return;
  612. my @formats;
  613. if (ref($ref) eq 'HASH' and exists($ref->{formats}) and ref($ref->{formats}) eq 'ARRAY') {
  614. foreach my $format (@{$ref->{formats}}) {
  615. if (exists($format->{format_id}) and exists($format->{url})) {
  616. my $entry = {
  617. itag => $format->{format_id},
  618. url => $format->{url},
  619. type => ((($format->{format} // '') =~ /audio only/i) ? 'audio/' : 'video/') . $format->{ext},
  620. };
  621. push @formats, $entry;
  622. }
  623. }
  624. }
  625. return @formats;
  626. }
  627. sub _fallback_extract_urls {
  628. my ($self, $videoID) = @_;
  629. my @formats;
  630. # Use yt-dlp / youtube-dl
  631. if ($self->get_ytdl and $self->_ytdl_is_available) {
  632. if ($self->get_debug) {
  633. my $cmd = $self->get_ytdl_cmd;
  634. say STDERR ":: Using $cmd to extract the streaming URLs...";
  635. }
  636. push @formats, $self->_extract_from_ytdl($videoID);
  637. if ($self->get_debug) {
  638. my $count = scalar(@formats);
  639. my $cmd = $self->get_ytdl_cmd;
  640. say STDERR ":: $cmd: found $count streaming URLs...";
  641. }
  642. @formats && return @formats;
  643. }
  644. # Use the API of invidious
  645. if ($self->get_debug) {
  646. say STDERR ":: Using invidious to extract the streaming URLs...";
  647. }
  648. push @formats, $self->_extract_from_invidious($videoID);
  649. if ($self->get_debug) {
  650. my $count = scalar(@formats);
  651. say STDERR ":: invidious: found $count streaming URLs...";
  652. }
  653. return @formats;
  654. }
  655. =head2 parse_query_string($string, multi => [0,1])
  656. Parse a query string and return a data structure back.
  657. When the B<multi> option is set to a true value, the function will store multiple values for a given key.
  658. Returns back a list of key-value pairs.
  659. =cut
  660. sub parse_query_string {
  661. my ($self, $str, %opt) = @_;
  662. if (not defined($str)) {
  663. return;
  664. }
  665. require URI::Escape;
  666. my @pairs;
  667. foreach my $statement (split(/,/, $str)) {
  668. foreach my $pair (split(/&/, $statement)) {
  669. push @pairs, $pair;
  670. }
  671. }
  672. my %result;
  673. foreach my $pair (@pairs) {
  674. my ($key, $value) = split(/=/, $pair, 2);
  675. if (not defined($value) or $value eq '') {
  676. next;
  677. }
  678. $value = URI::Escape::uri_unescape($value =~ tr/+/ /r);
  679. if ($opt{multi}) {
  680. push @{$result{$key}}, $value;
  681. }
  682. else {
  683. $result{$key} = $value;
  684. }
  685. }
  686. return %result;
  687. }
  688. sub _group_keys_with_values {
  689. my ($self, %data) = @_;
  690. my @hashes;
  691. foreach my $key (keys %data) {
  692. foreach my $i (0 .. $#{$data{$key}}) {
  693. $hashes[$i]{$key} = $data{$key}[$i];
  694. }
  695. }
  696. return @hashes;
  697. }
  698. sub _check_streaming_urls {
  699. my ($self, $videoID, $results) = @_;
  700. foreach my $video (@$results) {
  701. if ( exists $video->{s}
  702. or exists $video->{signatureCipher}
  703. or exists $video->{cipher}) { # has an encrypted signature :(
  704. if ($self->get_debug) {
  705. say STDERR ":: Detected an encrypted signature...";
  706. }
  707. my @formats = $self->_fallback_extract_urls($videoID);
  708. foreach my $format (@formats) {
  709. foreach my $ref (@$results) {
  710. if (defined($ref->{itag}) and ($ref->{itag} eq $format->{itag})) {
  711. $ref->{url} = $format->{url};
  712. last;
  713. }
  714. }
  715. }
  716. last;
  717. }
  718. }
  719. foreach my $video (@$results) {
  720. if (exists $video->{mimeType}) {
  721. $video->{type} = $video->{mimeType};
  722. }
  723. }
  724. return 1;
  725. }
  726. sub _extract_streaming_urls {
  727. my ($self, $json, $videoID) = @_;
  728. if ($self->get_debug) {
  729. say STDERR ":: Using `player_response` to extract the streaming URLs...";
  730. }
  731. if ($self->get_debug >= 2) {
  732. require Data::Dump;
  733. Data::Dump::pp($json);
  734. }
  735. ref($json) eq 'HASH' or return;
  736. my @results;
  737. if (exists $json->{streamingData}) {
  738. my $streamingData = $json->{streamingData};
  739. if (defined $streamingData->{dashManifestUrl}) {
  740. say STDERR ":: Contains DASH manifest URL" if $self->get_debug;
  741. ##return;
  742. }
  743. if (exists $streamingData->{adaptiveFormats}) {
  744. push @results, @{$streamingData->{adaptiveFormats}};
  745. }
  746. if (exists $streamingData->{formats}) {
  747. push @results, @{$streamingData->{formats}};
  748. }
  749. }
  750. $self->_check_streaming_urls($videoID, \@results);
  751. # Keep only streams with contentLength > 0.
  752. @results = grep { $_->{itag} == 22 or (exists($_->{contentLength}) and $_->{contentLength} > 0) } @results;
  753. # Filter out streams with "dur=0.000"
  754. @results = grep { $_->{url} !~ /\bdur=0\.000\b/ } grep { defined($_->{url}) } @results;
  755. # Detect livestream
  756. if (!@results and exists($json->{streamingData}) and exists($json->{streamingData}{hlsManifestUrl})) {
  757. if ($self->get_debug) {
  758. say STDERR ":: Live stream detected...";
  759. }
  760. # Extract with the fallback method
  761. @results = $self->_fallback_extract_urls($videoID);
  762. if (!@results) {
  763. push @results,
  764. {
  765. itag => 38,
  766. type => "video/mp4",
  767. wkad => 1,
  768. url => $json->{streamingData}{hlsManifestUrl},
  769. };
  770. }
  771. }
  772. if (!@results) {
  773. @results = $self->_fallback_extract_urls($videoID);
  774. }
  775. return @results;
  776. }
  777. sub _get_youtubei_content {
  778. my ($self, $endpoint, $videoID, %args) = @_;
  779. # Valid endpoints: browse, player, next
  780. my $url = sprintf($self->get_youtubei_url(), $endpoint);
  781. require Time::Piece;
  782. my $android_useragent = 'com.google.android.youtube/18.11.34 (Linux; U; Android 11) gzip';
  783. my %android = (
  784. "videoId" => $videoID,
  785. "context" => {
  786. "client" => {
  787. 'hl' => 'en',
  788. 'gl' => 'US',
  789. 'clientName' => 'ANDROID',
  790. 'clientVersion' => '18.11.34',
  791. 'androidSdkVersion' => 30,
  792. 'userAgent' => $android_useragent,
  793. %args,
  794. }
  795. },
  796. );
  797. $self->{lwp} // $self->set_lwp_useragent();
  798. my $agent = $self->{lwp}->agent;
  799. if ($endpoint ne 'next') {
  800. $self->{lwp}->agent($android_useragent);
  801. }
  802. my $client_version = sprintf("2.%s.00.00", Time::Piece->new(time)->strftime("%Y%m%d"));
  803. my %web = (
  804. "videoId" => $videoID,
  805. "context" => {
  806. "client" => {
  807. "hl" => "en",
  808. "gl" => "US",
  809. "clientName" => "WEB",
  810. "clientVersion" => $client_version,
  811. %args,
  812. },
  813. },
  814. );
  815. my %mweb = (
  816. "videoId" => $videoID,
  817. "context" => {
  818. "client" => {
  819. "hl" => "en",
  820. "gl" => "US",
  821. "clientName" => "MWEB",
  822. "clientVersion" => $client_version,
  823. %args,
  824. },
  825. },
  826. );
  827. if (0) {
  828. %android = %mweb;
  829. }
  830. local $self->{access_token} = undef;
  831. my $content;
  832. for (1 .. 3) {
  833. $content = $self->post_as_json($url, $endpoint eq 'next' ? \%mweb : \%android);
  834. last if defined $content;
  835. }
  836. $self->{lwp}->agent($agent);
  837. return $content;
  838. }
  839. sub _get_video_info {
  840. my ($self, $videoID, %args) = @_;
  841. my $content = $self->_get_youtubei_content('player', $videoID, %args);
  842. my %info = (player_response => $content);
  843. return %info;
  844. }
  845. sub _get_video_next_info {
  846. my ($self, $videoID) = @_;
  847. $self->_get_youtubei_content('next', $videoID);
  848. }
  849. sub _make_translated_captions {
  850. my ($self, $caption_urls) = @_;
  851. my @languages = qw(
  852. af am ar az be bg bn bs ca ceb co cs cy da de el en eo es et eu fa fi fil
  853. fr fy ga gd gl gu ha haw hi hmn hr ht hu hy id ig is it iw ja jv ka kk km
  854. kn ko ku ky la lb lo lt lv mg mi mk ml mn mr ms mt my ne nl no ny or pa pl
  855. ps pt ro ru rw sd si sk sl sm sn so sq sr st su sv sw ta te tg th tk tr tt
  856. ug uk ur uz vi xh yi yo zh-Hans zh-Hant zu
  857. );
  858. my %trans_languages = map { $_->{languageCode} => 1 } @$caption_urls;
  859. @languages = grep { not exists $trans_languages{$_} } @languages;
  860. my @asr;
  861. foreach my $caption (@$caption_urls) {
  862. foreach my $lang_code (@languages) {
  863. my %caption_copy = %$caption;
  864. $caption_copy{languageCode} = $lang_code;
  865. $caption_copy{baseUrl} = $caption_copy{baseUrl} . "&tlang=$lang_code";
  866. push @asr, \%caption_copy;
  867. }
  868. }
  869. return @asr;
  870. }
  871. sub _fallback_extract_captions {
  872. my ($self, $videoID) = @_;
  873. if ($self->get_debug) {
  874. my $cmd = $self->get_ytdl_cmd;
  875. say STDERR ":: Extracting closed-caption URLs with $cmd";
  876. }
  877. # Extract closed-caption URLs with yt-dlp / youtube-dl if our code failed
  878. my $ytdl_info = $self->_info_from_ytdl($videoID);
  879. my @caption_urls;
  880. if (defined($ytdl_info) and ref($ytdl_info) eq 'HASH') {
  881. my $has_subtitles = 0;
  882. foreach my $key (qw(subtitles automatic_captions)) {
  883. my $ccaps = $ytdl_info->{$key} // next;
  884. ref($ccaps) eq 'HASH' or next;
  885. foreach my $lang_code (sort keys %$ccaps) {
  886. my ($caption_info) = grep { $_->{ext} eq 'srv1' } @{$ccaps->{$lang_code}};
  887. if (defined($caption_info) and ref($caption_info) eq 'HASH' and defined($caption_info->{url})) {
  888. push @caption_urls,
  889. scalar {
  890. kind => ($key eq 'automatic_captions' ? 'asr' : ''),
  891. languageCode => $lang_code,
  892. baseUrl => $caption_info->{url},
  893. };
  894. if ($key eq 'subtitles') {
  895. $has_subtitles = 1;
  896. }
  897. }
  898. }
  899. last if $has_subtitles;
  900. }
  901. # Auto-translated captions
  902. if ($has_subtitles) {
  903. if ($self->get_debug) {
  904. say STDERR ":: Generating translated closed-caption URLs...";
  905. }
  906. push @caption_urls, $self->_make_translated_captions(\@caption_urls);
  907. }
  908. }
  909. return @caption_urls;
  910. }
  911. =head2 get_streaming_urls($videoID)
  912. Returns a list of streaming URLs for a videoID.
  913. ({itag=>..., url=>...}, {itag=>..., url=>....}, ...)
  914. =cut
  915. sub get_streaming_urls {
  916. my ($self, $videoID) = @_;
  917. no warnings 'redefine';
  918. local *_get_video_info = memoize(\&_get_video_info);
  919. ##local *_info_from_ytdl = memoize(\&_info_from_ytdl);
  920. ##local *_extract_from_ytdl = memoize(\&_extract_from_ytdl);
  921. my %info = $self->_get_video_info($videoID);
  922. my $json = defined($info{player_response}) ? $self->parse_json_string($info{player_response}) : {};
  923. if ($self->get_debug >= 2) {
  924. say STDERR ":: JSON data from player_response";
  925. require Data::Dump;
  926. Data::Dump::pp($json);
  927. }
  928. my @caption_urls;
  929. if (not defined($json->{streamingData})) {
  930. say STDERR ":: Trying to bypass age-restricted gate..." if $self->get_debug;
  931. my @fallback_methods;
  932. if ($self->get_bypass_age_gate_native) {
  933. push @fallback_methods, sub {
  934. %info =
  935. $self->_get_video_info(
  936. $videoID,
  937. "clientName" => "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
  938. "clientVersion" => "2.0"
  939. );
  940. };
  941. }
  942. if ($self->get_bypass_age_gate_with_proxy) {
  943. # See:
  944. # https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass/tree/main/account-proxy
  945. push @fallback_methods, sub {
  946. my $proxy_url = "https://youtube-proxy.zerody.one/getPlayer?";
  947. $proxy_url .= $self->list_to_url_arguments(
  948. videoId => $videoID,
  949. reason => "LOGIN_REQUIRED",
  950. clientName => "ANDROID",
  951. clientVersion => "16.20",
  952. hl => "en",
  953. );
  954. %info = (player_response => $self->lwp_get($proxy_url, simple => 1) // undef);
  955. };
  956. }
  957. # Try to find a fallback method that works
  958. foreach my $fallback_method (@fallback_methods) {
  959. $fallback_method->();
  960. $json = defined($info{player_response}) ? $self->parse_json_string($info{player_response}) : {};
  961. if (defined($json->{streamingData})) {
  962. push @caption_urls, $self->_fallback_extract_captions($videoID);
  963. last;
  964. }
  965. }
  966. }
  967. my @streaming_urls = $self->_extract_streaming_urls($json, $videoID);
  968. if (eval { ref($json->{captions}{playerCaptionsTracklistRenderer}{captionTracks}) eq 'ARRAY' }) {
  969. my @caption_tracks = @{$json->{captions}{playerCaptionsTracklistRenderer}{captionTracks}};
  970. my @human_made_cc = grep { ($_->{kind} // '') ne 'asr' } @caption_tracks;
  971. push @caption_urls, @human_made_cc, @caption_tracks;
  972. foreach my $caption (@caption_urls) {
  973. $caption->{baseUrl} =~ s{\bfmt=srv[0-9]\b}{fmt=srv1}g;
  974. }
  975. push @caption_urls, $self->_make_translated_captions(\@caption_urls);
  976. }
  977. # Try again with yt-dlp / youtube-dl
  978. if ( !@streaming_urls
  979. or (($json->{playabilityStatus}{status} // '') =~ /fail|error|unavailable|not available/i)
  980. or $self->get_force_fallback
  981. or (($json->{videoDetails}{videoId} // '') ne $videoID)) {
  982. @streaming_urls = $self->_fallback_extract_urls($videoID);
  983. if (!@caption_urls) {
  984. push @caption_urls, $self->_fallback_extract_captions($videoID);
  985. }
  986. }
  987. if ($self->get_debug) {
  988. my $count = scalar(@streaming_urls);
  989. say STDERR ":: Found $count streaming URLs...";
  990. }
  991. if ($self->get_prefer_mp4 or $self->get_prefer_av1) {
  992. my @video_urls;
  993. my @audio_urls;
  994. require WWW::YoutubeViewer::Itags;
  995. state $itags = WWW::YoutubeViewer::Itags::get_itags();
  996. my %audio_itags;
  997. @audio_itags{map { $_->{value} } @{$itags->{audio}}} = ();
  998. foreach my $url (@streaming_urls) {
  999. if (exists($audio_itags{$url->{itag}})) {
  1000. push @audio_urls, $url;
  1001. next;
  1002. }
  1003. if ($url->{type} =~ /\bvideo\b/i) {
  1004. if ($url->{type} =~ /\bav[0-9]+\b/i) { # AV1
  1005. if ($self->get_prefer_av1) {
  1006. push @video_urls, $url;
  1007. }
  1008. }
  1009. elsif ($self->get_prefer_mp4 and $url->{type} =~ /\bmp4\b/i) {
  1010. push @video_urls, $url;
  1011. }
  1012. }
  1013. else {
  1014. push @audio_urls, $url;
  1015. }
  1016. }
  1017. if (@video_urls) {
  1018. @streaming_urls = (@video_urls, @audio_urls);
  1019. }
  1020. }
  1021. # Filter out streams with `clen = 0`.
  1022. @streaming_urls = grep { defined($_->{clen}) ? ($_->{clen} > 0) : 1 } @streaming_urls;
  1023. # Return the YouTube URL when there are no streaming URLs
  1024. if (!@streaming_urls) {
  1025. push @streaming_urls,
  1026. {
  1027. itag => 38,
  1028. type => "video/mp4",
  1029. wkad => 1,
  1030. url => "https://www.youtube.com/watch?v=$videoID",
  1031. };
  1032. }
  1033. if ($self->get_debug >= 2) {
  1034. require Data::Dump;
  1035. Data::Dump::pp(\%info) if ($self->get_debug >= 3);
  1036. Data::Dump::pp(\@streaming_urls);
  1037. Data::Dump::pp(\@caption_urls);
  1038. }
  1039. return (\@streaming_urls, \@caption_urls, \%info);
  1040. }
  1041. sub from_page_token {
  1042. my ($self, $url, $token) = @_;
  1043. if (ref($token) eq 'CODE') {
  1044. return $token->();
  1045. }
  1046. my $pt_url = (
  1047. defined($token)
  1048. ? (
  1049. ($url =~ s/[?&]pageToken=\K[^&]+/$token/)
  1050. ? $url
  1051. : $self->_append_url_args($url, pageToken => $token)
  1052. )
  1053. : ($url =~ s/[?&]pageToken=[^&]+//r)
  1054. );
  1055. my $res = $self->_get_results($pt_url);
  1056. $res->{url} = $pt_url;
  1057. return $res;
  1058. }
  1059. # SUBROUTINE FACTORY
  1060. {
  1061. no strict 'refs';
  1062. # Create proxy_{exec,system} subroutines
  1063. foreach my $name ('exec', 'system', 'stdout') {
  1064. *{__PACKAGE__ . '::proxy_' . $name} = sub {
  1065. my ($self, @args) = @_;
  1066. $self->{lwp} // $self->set_lwp_useragent();
  1067. local $ENV{http_proxy} = $self->{lwp}->proxy('http');
  1068. local $ENV{https_proxy} = $self->{lwp}->proxy('https');
  1069. local $ENV{HTTP_PROXY} = $self->{lwp}->proxy('http');
  1070. local $ENV{HTTPS_PROXY} = $self->{lwp}->proxy('https');
  1071. local $" = " ";
  1072. $name eq 'exec' ? exec(@args)
  1073. : $name eq 'system' ? system(@args)
  1074. : $name eq 'stdout' ? qx(@args)
  1075. : ();
  1076. };
  1077. }
  1078. }
  1079. =head1 AUTHOR
  1080. Trizen, C<< <echo dHJpemVuQHByb3Rvbm1haWwuY29tCg== | base64 -d> >>
  1081. =head1 SEE ALSO
  1082. https://developers.google.com/youtube/v3/docs/
  1083. =head1 LICENSE AND COPYRIGHT
  1084. Copyright 2012-2015 Trizen.
  1085. This program is free software; you can redistribute it and/or modify it
  1086. under the terms of the the Artistic License (2.0). You may obtain a
  1087. copy of the full license at:
  1088. L<https://www.perlfoundation.org/artistic_license_2_0>
  1089. Any use, modification, and distribution of the Standard or Modified
  1090. Versions is governed by this Artistic License. By using, modifying or
  1091. distributing the Package, you accept this license. Do not use, modify,
  1092. or distribute the Package, if you do not accept this license.
  1093. If your Modified Version has been derived from a Modified Version made
  1094. by someone other than you, you are nevertheless required to ensure that
  1095. your Modified Version complies with the requirements of this license.
  1096. This license does not grant you the right to use any trademark, service
  1097. mark, tradename, or logo of the Copyright Holder.
  1098. This license includes the non-exclusive, worldwide, free-of-charge
  1099. patent license to make, have made, use, offer to sell, sell, import and
  1100. otherwise transfer the Package with respect to any patent claims
  1101. licensable by the Copyright Holder that are necessarily infringed by the
  1102. Package. If you institute patent litigation (including a cross-claim or
  1103. counterclaim) against any party alleging that the Package constitutes
  1104. direct or contributory patent infringement, then this Artistic License
  1105. to you shall terminate on the date that such litigation is filed.
  1106. Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
  1107. AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
  1108. THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
  1109. PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
  1110. YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
  1111. CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
  1112. CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
  1113. EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  1114. =cut
  1115. 1; # End of WWW::YoutubeViewer
  1116. __END__