subscribe.pl 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497
  1. #!/usr/bin/perl
  2. use strict;
  3. use warnings;
  4. use FindBin;
  5. use lib "$FindBin::Bin/";
  6. use File::Touch;
  7. use File::Basename;
  8. use Date::Parse;
  9. use Date::Format;
  10. use LWP::UserAgent;
  11. use URI::Escape;
  12. use JSON;
  13. use XML::LibXML;
  14. use IPC::System::Simple qw( system run capture EXIT_ANY $EXITVAL );
  15. use IPC::Cmd qw( can_run );
  16. use Cwd qw( abs_path );
  17. use PrettyPrint;
  18. # Paramters
  19. my %USERS = ('profplump' => 'UCkj-Ob6eYHvzo-P0UWfnQzA', 'shanda' => 'UChfwMHzkPXOOFDce5hyQkTA');
  20. my $EXTRAS_FILE = 'extra_videos.ini';
  21. my $EXCLUDES_FILE = 'exclude_videos.ini';
  22. my $YTDL_BIN = $ENV{'HOME'} . '/bin/video/yt/youtube-dl';
  23. my @YTDL_ARGS = ('--force-ipv4', '--socket-timeout', '10', '--retries', '1',
  24. '--no-playlist', '--max-downloads', '1', '--age-limit', '99');
  25. my @YTDL_QUIET = ('--quiet', '--no-warnings');
  26. my @YTDL_DEBUG = ('--verbose');
  27. my $CHATTR = '/usr/bin/chattr';
  28. my $BATCH_SIZE = int(rand(32));
  29. my $MAX_INDEX = 25000;
  30. my $FETCH_LIMIT = 50;
  31. my $DELAY = 5;
  32. my $MIN_DELAY = 0.75;
  33. my $FORK_DELAY = 5;
  34. my $HTTP_TIMEOUT = 12;
  35. my $HTTP_UA = 'ZachBot/1.1 (Firewall)';
  36. my $HTTP_VERIFY = 0;
  37. my $API_HTML = $ENV{'API_HTML'};
  38. my $API_URL = 'https://www.googleapis.com/youtube/v3/';
  39. my $API_KEY = $ENV{'YT_API_KEY'};
  40. my $API_ST_REL = 0.50;
  41. my $API_ST_ABS = 5;
  42. my %VIDEO_FIELDS = (
  43. 'id' => '',
  44. 'title' => '',
  45. 'description' => '',
  46. 'creator' => '',
  47. 'publishedAt' => '',
  48. 'channelId' => '',
  49. 'rawDuration' => '',
  50. 'date', => 0,
  51. 'season' => 0,
  52. 'duration' => 0,
  53. );
  54. my %API = (
  55. #https://www.googleapis.com/youtube/v3/channels?part=id&forUsername={channelName}&key={YOUR_API_KEY}
  56. 'channelID' => {
  57. 'url' => $API_URL . 'channels',
  58. 'params' => {
  59. 'part' => 'id',
  60. 'key' => $API_KEY,
  61. },
  62. },
  63. #https://www.googleapis.com/youtube/v3/channels?part=snippet&id={channelID}&key={YOUR_API_KEY}
  64. 'channel' => {
  65. 'url' => $API_URL . 'channels',
  66. 'params' => {
  67. 'part' => 'snippet',
  68. 'key' => $API_KEY,
  69. },
  70. },
  71. #https://www.googleapis.com/youtube/v3/search?part=snippet&channelId={channelID}&maxResults={maxResults}&pageToken={foo}&safeSearch=none&type=video&key={YOUR_API_KEY}
  72. 'search' => {
  73. 'url' => $API_URL . 'search',
  74. 'params' => {
  75. 'part' => 'id',
  76. 'key' => $API_KEY,
  77. 'maxResults' => 5,
  78. 'safeSearch' => 'none',
  79. 'type' => 'video',
  80. 'order' => 'date',
  81. },
  82. },
  83. #https://www.googleapis.com/youtube/v3/videos?part=snippet&id={videoID}&key={YOUR_API_KEY}
  84. 'video' => {
  85. 'url' => $API_URL . 'videos',
  86. 'params' => {
  87. 'part' => 'snippet,contentDetails',
  88. 'key' => $API_KEY,
  89. },
  90. },
  91. #https://www.googleapis.com/youtube/v3/subscriptions?part=snippet&channelId={channelID}&key={YOUR_API_KEY}
  92. 'subscriptions' => {
  93. 'url' => $API_URL . 'subscriptions',
  94. 'params' => {
  95. 'part' => 'snippet',
  96. 'key' => $API_KEY,
  97. 'maxResults' => 5,
  98. },
  99. },
  100. );
  101. # Prototypes
  102. sub getVideoData($);
  103. sub localMetadata($$);
  104. sub findVideos($);
  105. sub findFiles($);
  106. sub buildNFO($);
  107. sub buildSeriesNFO($);
  108. sub getSubscriptions($);
  109. sub saveSubscriptions($$);
  110. sub saveChannel($);
  111. sub getChannel($);
  112. sub fetchParse($$);
  113. sub saveString($$);
  114. sub readExcludes();
  115. sub readExtras();
  116. sub parseVideoData($);
  117. sub calcVideoData($);
  118. sub dropExcludes($);
  119. sub addExtras($);
  120. sub updateNFOData($$$);
  121. sub videoNumberStr($$);
  122. sub videoSE($$);
  123. sub videoPath($$$$);
  124. sub renameVideo($$$$$$);
  125. sub parseFilename($);
  126. sub fetch($$);
  127. sub delay(;$);
  128. # Debug
  129. my $DEBUG = 0;
  130. if ($ENV{'DEBUG'}) {
  131. if ($ENV{'DEBUG'} =~ /(\d+)/) {
  132. $DEBUG = $1;
  133. } else {
  134. $DEBUG = 1;
  135. }
  136. $DELAY = 0;
  137. }
  138. # Sanity check
  139. if (scalar(@ARGV) < 1) {
  140. die('Usage: ' . basename($0) . " output_directory\n");
  141. }
  142. if (!defined($API_KEY) || length($API_KEY) < 1) {
  143. die("No API key provided\n");
  144. }
  145. my $SUBSCRIPTIONS = 0;
  146. if ($0 =~ /subscription/i) {
  147. $SUBSCRIPTIONS = 1;
  148. }
  149. # Command-line parameters
  150. our ($DIR) = @ARGV;
  151. $DIR = Cwd::abs_path($DIR);
  152. $DIR =~ s/\/+$//;
  153. if (!-d $DIR) {
  154. die('Invalid output directory: ' . $DIR . "\n");
  155. }
  156. our $NAME = basename($DIR);
  157. our $ID = $NAME;
  158. if ($NAME =~ /^(.*)\s\(([\w\-]+)\)$/) {
  159. $NAME = $1;
  160. $ID = $2;
  161. if (length($ID) < 1 || !($ID =~ /^[\w\-]+$/)) {
  162. die('Invalid channel ID: ' . $ID . "\n");
  163. }
  164. } elsif (!$SUBSCRIPTIONS) {
  165. die('Invalid folder: ' . $DIR . "\n");
  166. }
  167. # Allow folders to disable themselves
  168. if (-e "${DIR}/season_done") {
  169. if ($DEBUG) {
  170. print STDERR "Folder disabled. Exiting...\n";
  171. }
  172. exit(0);
  173. }
  174. # Move to the target directory so we can use relative paths later
  175. chdir($DIR) or die('Unable to chdir to: ' . $DIR . "\n");
  176. # Environmental parameters (debug)
  177. my $NO_FETCH = 0;
  178. if ($ENV{'NO_FETCH'}) {
  179. $NO_FETCH = 1;
  180. }
  181. my $NO_NFO = 0;
  182. if ($ENV{'NO_NFO'}) {
  183. $NO_NFO = 1;
  184. }
  185. my $NO_SEARCH = 0;
  186. if ($ENV{'NO_SEARCH'}) {
  187. $NO_SEARCH = 1;
  188. }
  189. my $NO_FILES = 0;
  190. if ($ENV{'NO_FILES'}) {
  191. $NO_FILES = 1;
  192. }
  193. my $NO_CHANNEL = 0;
  194. if ($ENV{'NO_CHANNEL'}) {
  195. $NO_CHANNEL = 1;
  196. }
  197. my $NO_EXTRAS = 0;
  198. if ($ENV{'NO_EXTRAS'}) {
  199. $NO_EXTRAS = 1;
  200. }
  201. my $NO_EXCLUDES = 0;
  202. if ($ENV{'NO_EXCLUDES'}) {
  203. $NO_EXCLUDES = 1;
  204. }
  205. my $NO_RENAME = 0;
  206. if ($ENV{'NO_RENAME'}) {
  207. $NO_RENAME = 1;
  208. }
  209. # Environmental parameters (functional)
  210. my $SUDO_CHATTR = 1;
  211. if ($ENV{'NO_CHATTR'} || !can_run('sudo') || !can_run($CHATTR)) {
  212. $SUDO_CHATTR = 0;
  213. }
  214. if ($ENV{'MAX_INDEX'} && $ENV{'MAX_INDEX'} =~ /(\d+)/) {
  215. $MAX_INDEX = $1;
  216. }
  217. if ($ENV{'BATCH_SIZE'} && $ENV{'BATCH_SIZE'} =~ /(\d+)/) {
  218. $BATCH_SIZE = $1;
  219. }
  220. if (exists($ENV{'FETCH_LIMIT'}) && $ENV{'FETCH_LIMIT'} =~ /(\d+)/) {
  221. $FETCH_LIMIT = $1;
  222. }
  223. # Construct globals
  224. my $LWP = undef();
  225. foreach my $key (keys(%API)) {
  226. if (exists($API{$key}{'params'}{'maxResults'})) {
  227. $API{$key}{'params'}{'maxResults'} = $BATCH_SIZE;
  228. }
  229. }
  230. if ($DEBUG > 1) {
  231. push(@YTDL_ARGS, @YTDL_DEBUG);
  232. } elsif (!$DEBUG) {
  233. push(@YTDL_ARGS, @YTDL_QUIET);
  234. }
  235. # Allow use as a subscription manager
  236. if ($SUBSCRIPTIONS) {
  237. my %subs = ();
  238. foreach my $user (keys(%USERS)) {
  239. my $tmp = getSubscriptions($USERS{$user});
  240. foreach my $sub (keys(%{$tmp})) {
  241. $subs{$sub} = $tmp->{$sub};
  242. }
  243. }
  244. saveSubscriptions($DIR, \%subs);
  245. exit(0);
  246. }
  247. # Disable channel fetching if the ID channel is "None"
  248. if ($ID =~ /^\s*None\s*$/i) {
  249. if ($DEBUG) {
  250. print STDERR 'Channel features disabled for: ' . $NAME . "\n";
  251. }
  252. $NO_CHANNEL = 1;
  253. $NO_SEARCH = 1;
  254. }
  255. # Run the local "update" script if any
  256. {
  257. my @update = ('./update', $DIR);
  258. if (can_run($update[0])) {
  259. if ($DEBUG) {
  260. print STDERR "Executing local update script...\n";
  261. }
  262. system(@update);
  263. }
  264. }
  265. # Grab the channel data
  266. my $channel = {};
  267. if (!$NO_CHANNEL) {
  268. $channel = getChannel($ID);
  269. saveChannel($channel);
  270. }
  271. # Find all the user's videos on YT
  272. my $videos = {};
  273. if (!$NO_SEARCH) {
  274. $videos = findVideos($ID);
  275. }
  276. # Find any requested "extra" videos
  277. my $extras = {};
  278. if (!$NO_EXTRAS) {
  279. $extras = addExtras($videos);
  280. }
  281. # Drop any "excludes" videos
  282. my $excludes = {};
  283. if (!$NO_EXCLUDES) {
  284. $excludes = dropExcludes($videos);
  285. }
  286. # Find all existing YT files on disk
  287. my $files = {};
  288. if (!$NO_FILES) {
  289. $files = findFiles($videos);
  290. }
  291. # Deal with unknown videos
  292. foreach my $id (keys(%{$files})) {
  293. if ((!exists($videos->{$id}) || !$videos->{$id}->{'date'}) && $files->{$id}->{'season'} > 0) {
  294. # Check the ID specifically to find things the APIv3 fuzzy search misses
  295. if ($DEBUG) {
  296. print STDERR 'Validating local video by ID: ' . $id . "\n";
  297. }
  298. my $video = getVideoData([$id]);
  299. if ($video->[0] && $video->[0]->{'season'} > 0 && $video->[0]->{'channelId'} eq $ID) {
  300. $videos->{$id} = $video->[0];
  301. next;
  302. }
  303. # Whine when videos do go away
  304. if ($DEBUG) {
  305. warn('Local video not known to YT channel (' . $NAME . '): ' . $id . "\n");
  306. }
  307. renameVideo($files->{$id}->{'path'}, $files->{$id}->{'suffix'}, $files->{$id}->{'nfo'}, $id, 0, $files->{$id}->{'season'} . $files->{$id}->{'number'});
  308. }
  309. }
  310. # Calculate the episode number using the publish dates
  311. {
  312. my @byDate = sort { $videos->{$a}->{'date'} <=> $videos->{$b}->{'date'} || $a cmp $b } keys %{$videos};
  313. my $num = undef();
  314. my $lastSeason = undef();
  315. foreach my $id (@byDate) {
  316. if (!$lastSeason || $lastSeason != $videos->{$id}->{'season'}) {
  317. $num = 1;
  318. $lastSeason = $videos->{$id}->{'season'};
  319. }
  320. $videos->{$id}->{'number'} = $num++;
  321. }
  322. }
  323. # HTML output
  324. if ($API_HTML) {
  325. my $html = undef();
  326. my $hdate = time() - (86400 * 28);
  327. open($html, '>', $API_HTML) or warn('Could not open file "' . $API_HTML . '": ' . $!);
  328. if (!$html) {
  329. goto END_HTML;
  330. }
  331. print $html '<html><head><title>' . $NAME . '(' . $ID . ')' . '</title></head>' . "\n";
  332. print $html '<body><form method="post" action="https://firewall.local/yt/update.py">' . "\n";
  333. print $html '<h1>' . $NAME . '</h1><table>' . "\n";
  334. my @byDate = sort { $videos->{$b}->{'date'} <=> $videos->{$a}->{'date'} || $a cmp $b } keys %{$videos};
  335. foreach my $id (@byDate) {
  336. my $val = 'Block';
  337. if ($videos->{$id}->{'date'} > $hdate) {
  338. $val = 'Allow';
  339. }
  340. print $html '<tr>';
  341. print $html '<td>' . time2str('%Y-%m-%d', $videos->{$id}->{'date'}) . '</td>';
  342. print $html '<td><a target="_blank" rel="noopener" href="https://www.youtube.com/watch?v='
  343. . $id . '">' . $videos->{$id}->{'title'} . '</a></td>';
  344. print $html '<td><input type="submit" name="ytid|' . $id . '" value="' . $val . '" /></td>';
  345. print $html "</tr>\n";
  346. }
  347. print $html '</table></form></body></html>' . "\n";
  348. close($html);
  349. END_HTML:
  350. }
  351. # Fill in missing videos and NFOs
  352. my $fetched = 0;
  353. FETCH_LOOP: foreach my $id (keys(%{$videos})) {
  354. if ($DEBUG > 1) {
  355. print STDERR 'Checking remote video: ' . $id . "\n";
  356. if (!exists($files->{$id})) {
  357. print STDERR "\tLocal file: <missing>\n";
  358. } else {
  359. print STDERR "\tLocal media: " . $files->{$id}->{'path'} . "\n";
  360. print STDERR "\tLocal NFO: ";
  361. if ($files->{$id}->{'nfo'}) {
  362. print STDERR $files->{$id}->{'nfo'} . "\n";
  363. } else {
  364. print STDERR "<none>\n";
  365. }
  366. }
  367. }
  368. my $nfo = videoPath($videos->{$id}->{'season'}, $videos->{$id}->{'number'}, $id, 'nfo');
  369. # Determine if we may or must rename
  370. my $rename = 0;
  371. if (exists($files->{$id})) {
  372. # Rename if we drift
  373. if (!$rename && $files->{$id}->{'path'}) {
  374. if ( $files->{$id}->{'number'} != $videos->{$id}->{'number'}
  375. || $files->{$id}->{'season'} != $videos->{$id}->{'season'})
  376. {
  377. if ($DEBUG) {
  378. print STDERR "Rename due to drift\n";
  379. }
  380. $rename = 1;
  381. }
  382. }
  383. # Always rename if the NFO does not match the media
  384. if (!$rename && $files->{$id}->{'path'} && $files->{$id}->{'nfo'}) {
  385. my ($season, $number) = parseFilename($files->{$id}->{'nfo'});
  386. if ($files->{$id}->{'season'} != $season || $files->{$id}->{'number'} != $number) {
  387. if ($DEBUG) {
  388. print STDERR "Rename due to media-metadata mismatch\n";
  389. }
  390. $rename = 1;
  391. }
  392. }
  393. }
  394. # Warn (and optionally rename) as selected
  395. if ($rename) {
  396. if ($DEBUG) {
  397. warn('Video ' . $id . ' had number ' . $files->{$id}->{'number'} . ' but now has number ' . $videos->{$id}->{'number'} . "\n");
  398. }
  399. renameVideo($files->{$id}->{'path'}, $files->{$id}->{'suffix'}, $files->{$id}->{'nfo'}, $id, $videos->{$id}->{'season'}, $videos->{$id}->{'number'});
  400. }
  401. # If we haven't heard of the file, or don't have an NFO for it
  402. # Checking for the NFO allows us to resume in-process failures
  403. if (!exists($files->{$id}) || !-e $nfo) {
  404. if ($DEBUG) {
  405. print STDERR 'Fetching video: ' . $id . "\n";
  406. }
  407. # Mark the video as reviewed
  408. {
  409. my $reviewed = videoSE($videos->{$id}->{'season'}, $videos->{$id}->{'number'}) . $id . '.reviewed';
  410. touch($reviewed);
  411. my @args = ('--output', videoSE($videos->{$id}->{'season'}, $videos->{$id}->{'number'}) . '%(id)s.%(ext)s', '--', $id);
  412. my @name = ($YTDL_BIN);
  413. push(@name, @YTDL_ARGS);
  414. my @fetch = @name;
  415. push(@name, '--get-filename');
  416. push(@fetch, @args);
  417. push(@name, @args);
  418. if ($NO_FETCH) {
  419. print STDERR 'Not running: ' . join(' ', @fetch) . "\n";
  420. } else {
  421. # Count fetch attempts (even if they fail later)
  422. $fetched++;
  423. if ($FETCH_LIMIT && $fetched > $FETCH_LIMIT) {
  424. print STDERR 'Reached fetch limit (' . $FETCH_LIMIT . ') for: ' . $NAME . "\n";
  425. if ($DEBUG) {
  426. print STDERR "\tLocal/Remote videos at start:" . scalar(keys(%{$files})) . '/' . scalar(keys(%{$videos})) . "\n";
  427. }
  428. exit 1;
  429. }
  430. # Find the output file name
  431. if ($DEBUG > 1) {
  432. print STDERR join(' ', @name) . "\n";
  433. }
  434. delay();
  435. my $file = capture(EXIT_ANY, @name);
  436. if ($EXITVAL != 0) {
  437. warn('Error executing youtube-dl for name: ' . $NAME . '/' . $id . ' (' . $EXITVAL . ")\n");
  438. next FETCH_LOOP;
  439. }
  440. if ($file =~ /\n\S/) {
  441. $file =~ s/^.*\n//;
  442. }
  443. $file =~ s/^\s+//;
  444. $file =~ s/\s+$//;
  445. # Sanity check
  446. if (!$file) {
  447. warn('No file name available for video: ' . $NAME . '/' . $id . "\n");
  448. next FETCH_LOOP;
  449. } elsif ($DEBUG > 0) {
  450. print STDERR 'Output video file: ' . $file . "\n";
  451. }
  452. # Download
  453. if ($DEBUG > 1) {
  454. print STDERR join(' ', @fetch) . "\n";
  455. }
  456. delay();
  457. my $exit = run(EXIT_ANY, @fetch);
  458. if ($exit != 0) {
  459. if ($DEBUG) {
  460. warn('Error executing youtube-dl for video: ' . $NAME . '/' . $id . "\n");
  461. }
  462. next FETCH_LOOP;
  463. }
  464. # Ensure we found something useful
  465. if (-e $file . '.part') {
  466. warn('Partial download detected: ' . $NAME . '/' . $file . "\n");
  467. next FETCH_LOOP;
  468. }
  469. # Sometimes the downloader lies about file extensions
  470. if (!-s $file) {
  471. $file =~ s/\.webm$/.mp4/;
  472. }
  473. if (!-s $file) {
  474. $file =~ s/\.mp4$/.webm/;
  475. }
  476. # But we still need *a* file
  477. if (!-s $file) {
  478. warn('No output video file: ' . $NAME . '/' . $file . "\n");
  479. next FETCH_LOOP;
  480. }
  481. # Touch the file to reflect the download time rather than the upload time
  482. touch($file);
  483. }
  484. }
  485. # Build and save the XML document
  486. my $xml = buildNFO($videos->{$id});
  487. if ($DEBUG > 1) {
  488. print STDERR 'Saving NFO: ' . $xml . "\n";
  489. }
  490. if ($NO_NFO) {
  491. print STDERR "Not saving NFO\n";
  492. } else {
  493. saveString($nfo, $xml);
  494. }
  495. }
  496. }
  497. sub saveString($$) {
  498. my ($path, $str) = @_;
  499. my $fh = undef();
  500. if (!open($fh, '>', $path)) {
  501. warn('Cannot open file for writing: ' . $path . ': ' . $! . "\n");
  502. return undef();
  503. }
  504. print $fh $str;
  505. close($fh);
  506. return 1;
  507. }
  508. sub buildSeriesNFO($) {
  509. my ($channel) = @_;
  510. # Create an XML tree
  511. my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
  512. $doc->setStandalone(1);
  513. my $show = $doc->createElement('tvshow');
  514. $doc->setDocumentElement($show);
  515. my $elm;
  516. # Add data
  517. $elm = $doc->createElement('title');
  518. $elm->appendText($channel->{'title'});
  519. $show->appendChild($elm);
  520. $elm = $doc->createElement('premiered');
  521. $elm->appendText(time2str('%Y-%m-%d', $channel->{'date'}));
  522. $show->appendChild($elm);
  523. $elm = $doc->createElement('plot');
  524. $elm->appendText($channel->{'description'});
  525. $show->appendChild($elm);
  526. # Return the string
  527. return $doc->toString();
  528. }
  529. sub buildNFO($) {
  530. my ($video) = @_;
  531. # Create an XML tree
  532. my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
  533. $doc->setStandalone(1);
  534. my $show = $doc->createElement('episodedetails');
  535. $doc->setDocumentElement($show);
  536. my $file = $doc->createElement('fileinfo');
  537. $show->appendChild($file);
  538. my $stream = $doc->createElement('streamdetails');
  539. $file->appendChild($stream);
  540. my $stream_video = $doc->createElement('video');
  541. $stream->appendChild($stream_video);
  542. my $elm;
  543. # Add data
  544. $elm = $doc->createElement('season');
  545. $elm->appendText($video->{'season'});
  546. $show->appendChild($elm);
  547. $elm = $doc->createElement('episode');
  548. $elm->appendText(videoNumberStr($video->{'season'}, $video->{'number'}));
  549. $show->appendChild($elm);
  550. if (!defined($video->{'title'})) {
  551. $video->{'title'} = 'Episode ' . $video->{'number'};
  552. }
  553. $elm = $doc->createElement('title');
  554. $elm->appendText($video->{'title'});
  555. $show->appendChild($elm);
  556. if (!defined($video->{'date'})) {
  557. $video->{'date'} = time();
  558. }
  559. $elm = $doc->createElement('aired');
  560. $elm->appendText(time2str('%Y-%m-%d', $video->{'date'}));
  561. $show->appendChild($elm);
  562. if (defined($video->{'description'})) {
  563. $elm = $doc->createElement('plot');
  564. $elm->appendText($video->{'description'});
  565. $show->appendChild($elm);
  566. }
  567. if (defined($video->{'duration'})) {
  568. $elm = $doc->createElement('runtime');
  569. $elm->appendText($video->{'duration'});
  570. $show->appendChild($elm);
  571. $elm = $doc->createElement('durationinseconds');
  572. $elm->appendText($video->{'duration'});
  573. $stream_video->appendChild($elm);
  574. }
  575. if (defined($video->{'creator'})) {
  576. $elm = $doc->createElement('director');
  577. $elm->appendText($video->{'creator'});
  578. $show->appendChild($elm);
  579. }
  580. # Return the string
  581. return $doc->toString();
  582. }
  583. sub findFiles($) {
  584. my ($videos) = @_;
  585. my %files = ();
  586. our $NAME;
  587. # Allow complete bypass
  588. if ($NO_FILES) {
  589. return \%files;
  590. }
  591. # Read the output directory
  592. my $fh = undef();
  593. opendir($fh, '.')
  594. or die($NAME . ': Unable to open files directory: ' . $! . "\n");
  595. while (my $file = readdir($fh)) {
  596. my ($season, $number, $id, $suffix) = parseFilename($file);
  597. if (defined($id) && length($id) > 0) {
  598. # Determine if there is another video with the same season/number
  599. my $exists = 0;
  600. if ( exists($videos->{$id})
  601. && exists($videos->{$id}->{'season'})
  602. && exists($videos->{$id}->{'number'})
  603. && $season == $videos->{$id}->{'season'}
  604. && $number == $videos->{$id}->{'number'})
  605. {
  606. $exists = 1;
  607. }
  608. # Create the record as needed
  609. if (!exists($files{$id})) {
  610. my %tmp = (
  611. 'season' => $season,
  612. 'number' => $number,
  613. );
  614. $files{$id} = \%tmp;
  615. }
  616. # Video or NFO?
  617. my $type = 'path';
  618. if ($suffix eq 'nfo') {
  619. $type = 'nfo';
  620. }
  621. # Deal with duplicates
  622. if (exists($files{$id}->{$type})) {
  623. if ($type eq 'nfo') {
  624. warn('Duplicate NFO: ' . $id . "\n\t" . $files{$id}->{'nfo'} . "\n\t" . $file . "\n");
  625. if (!$NO_RENAME) {
  626. my $del = $file;
  627. if ($exists) {
  628. $del = $files{$id}->{'nfo'};
  629. }
  630. warn("\tDeleting: " . $file . "\n");
  631. if ($SUDO_CHATTR) {
  632. system('sudo', $CHATTR, '-i', Cwd::abs_path($file));
  633. }
  634. unlink($file);
  635. next;
  636. }
  637. } else {
  638. warn('Duplicate video: ' . $id . "\n\t" . $files{$id}->{'path'} . "\n\t" . $file . "\n");
  639. if (!$NO_RENAME) {
  640. my $del = $file;
  641. if ($exists) {
  642. $del = $files{$id}->{'path'};
  643. } elsif ($suffix ne 'mp4' && $files{$id}->{'suffix'} eq 'mp4') {
  644. $del = $files{$id}->{'path'};
  645. } elsif ($suffix ne 'webm' && $files{$id}->{'suffix'} eq 'webm') {
  646. $del = $files{$id}->{'path'};
  647. }
  648. warn("\tDeleting: " . $del . "\n");
  649. if ($SUDO_CHATTR) {
  650. system('sudo', $CHATTR, '-i', Cwd::abs_path($del));
  651. }
  652. unlink($del);
  653. next;
  654. }
  655. }
  656. }
  657. # Assign the component paths
  658. $files{$id}->{$type} = $file;
  659. if ($type ne 'nfo') {
  660. $files{$id}->{'suffix'} = $suffix;
  661. }
  662. }
  663. }
  664. close($fh);
  665. if ($DEBUG) {
  666. print STDERR 'Found ' . scalar(keys(%files)) . " local videos\n";
  667. if ($DEBUG > 1) {
  668. print STDERR prettyPrint(\%files, "\t") . "\n";
  669. }
  670. }
  671. return \%files;
  672. }
  673. sub fetchParse($$) {
  674. my ($name, $params) = @_;
  675. our $NAME;
  676. my $url = $API{$name}{'url'} . '?';
  677. foreach my $key (keys(%{ $API{$name}{'params'} }), keys(%{$params})) {
  678. $url .= '&' . uri_escape($key) . '=';
  679. if (exists($params->{$key})) {
  680. $url .= uri_escape($params->{$key});
  681. } else {
  682. $url .= uri_escape($API{$name}{'params'}{$key});
  683. }
  684. }
  685. # Fetch
  686. if ($DEBUG > 1) {
  687. print STDERR 'Fetching ' . $name . ' API URL: ' . $url . "\n";
  688. }
  689. delay();
  690. my $content;
  691. my $code = fetch($url, \$content);
  692. if ($code != 200 || !defined($content) || length($content) < 10) {
  693. my $msg = $NAME . ': Invalid content from URL (' . $code . '): ' . $url . "\n" . $content . "\n";
  694. if ($code >= 500 && $code < 600) {
  695. if ($DEBUG) {
  696. print STDERR $msg;
  697. }
  698. exit(0);
  699. }
  700. if ($code >= 400 && $code < 500) {
  701. if ($DEBUG) {
  702. print STDERR "Waiting 30 seconds after 400 error\n";
  703. }
  704. delay(30);
  705. }
  706. die($msg);
  707. }
  708. # Parse
  709. if ($DEBUG > 2) {
  710. print STDERR "Raw JSON data:\n" . $content . "\n";
  711. }
  712. my $data = decode_json($content);
  713. if (!defined($data) || ref($data) ne 'HASH') {
  714. die($NAME . ': Invalid JSON: ' . $content . "\n");
  715. }
  716. return $data;
  717. }
  718. sub getSubscriptions($) {
  719. my ($id) = @_;
  720. our $NAME;
  721. # Loop through until we have all the entries
  722. my $pageToken = '';
  723. my %subs = ();
  724. SUBS_LOOP:
  725. {
  726. # Build, fetch, parse, check
  727. my $data = fetchParse('subscriptions', { 'channelId' => $id, 'pageToken' => $pageToken });
  728. $pageToken = '';
  729. if ( exists($data->{'pageInfo'})
  730. && ref($data->{'pageInfo'}) eq 'HASH'
  731. && exists($data->{'pageInfo'}->{'totalResults'})
  732. && $data->{'pageInfo'}->{'totalResults'} > 0
  733. && exists($data->{'pageInfo'}->{'resultsPerPage'})
  734. && exists($data->{'items'})
  735. && ref($data->{'items'}) eq 'ARRAY')
  736. {
  737. if (exists($data->{'nextPageToken'})) {
  738. $pageToken = $data->{'nextPageToken'};
  739. }
  740. $data = $data->{'items'};
  741. } else {
  742. die($NAME . ": Invalid subscription data\n");
  743. }
  744. foreach my $item (@{$data}) {
  745. if ( !exists($item->{'snippet'})
  746. || ref($item->{'snippet'}) ne 'HASH'
  747. || !exists($item->{'snippet'}->{'title'})
  748. || !exists($item->{'snippet'}->{'resourceId'})
  749. || ref($item->{'snippet'}->{'resourceId'}) ne 'HASH'
  750. || !exists($item->{'snippet'}->{'resourceId'}->{'channelId'}))
  751. {
  752. if ($DEBUG > 2) {
  753. warn($NAME . ": Skipping invalid subscription\n");
  754. print STDERR prettyPrint($item, "\t") . "\n";
  755. }
  756. next;
  757. }
  758. if ($DEBUG) {
  759. print STDERR $item->{'snippet'}->{'title'} . ' => ' . $item->{'snippet'}->{'resourceId'}->{'channelId'} . "\n";
  760. }
  761. $subs{ $item->{'snippet'}->{'resourceId'}->{'channelId'} } = $item->{'snippet'}->{'title'};
  762. }
  763. # Loop if there are results left to fetch
  764. if ($pageToken) {
  765. redo SUBS_LOOP;
  766. }
  767. }
  768. # Return the list of subscribed channel IDs
  769. return \%subs;
  770. }
  771. sub saveSubscriptions($$) {
  772. my ($folder, $subs) = @_;
  773. our $NAME;
  774. # Check for local subscriptions missing from YT
  775. my %locals = ();
  776. my $fh = undef();
  777. opendir($fh, '.')
  778. or die($NAME . ': Unable to open subscriptions directory: ' . $folder . ': ' . $! . "\n");
  779. while (my $file = readdir($fh)) {
  780. # Skip dotfiles
  781. if ($file =~ /^\./) {
  782. next;
  783. }
  784. # Skip non-directories
  785. if (!-d $file) {
  786. next;
  787. }
  788. # Extract the YTID
  789. my ($id) = $file =~ /\(([\w\-]+)\)$/;
  790. if (!$id) {
  791. warn('Skipping invalid local subscription: ' . $file . "\n");
  792. next;
  793. }
  794. # Skip "None" directories (i.e. non-channel subscriptions)
  795. if ($id eq 'None') {
  796. if ($DEBUG) {
  797. print STDERR 'Ignoring non-channel subscription: ' . $file . "\n";
  798. }
  799. next;
  800. }
  801. # Skip the weird dual Ask a Ninja thing, which vascilates
  802. if ($id eq 'UCpYQ53cR1p2VzFijrXYz4WQ') {
  803. if ($DEBUG) {
  804. print STDERR 'Ignoring weird Ask a Ninja subscription: ' . $file . "\n";
  805. }
  806. next;
  807. }
  808. # Anything else should be in the list
  809. if (!$subs->{$id}) {
  810. my (undef(), undef(), $hour) = localtime(time);
  811. if ($hour == 0 || $DEBUG) {
  812. print STDERR 'Missing YT subscription for: ' . $file . "\n";
  813. }
  814. }
  815. # Note local subscriptions
  816. $locals{$id} = 1;
  817. }
  818. closedir($fh);
  819. # Check for YT subscriptions missing locally
  820. foreach my $id (keys(%{$subs})) {
  821. if (!exists($locals{$id})) {
  822. my $dir = $subs->{$id} . ' (' . $id . ')';
  823. print STDERR 'Adding local subscription for: ' . $dir . "\n";
  824. mkdir($folder . '/' . $dir);
  825. }
  826. }
  827. }
  828. sub saveChannel($) {
  829. my ($channel) = @_;
  830. my $nfo = 'tvshow.nfo';
  831. if (!-e $nfo) {
  832. if ($DEBUG) {
  833. print STDERR 'Saving series data for: ' . $channel->{'title'} . "\n";
  834. }
  835. # Save the poster
  836. if (exists($channel->{'thumbnail'}) && length($channel->{'thumbnail'}) > 5) {
  837. my $jpg;
  838. if (fetch($channel->{'thumbnail'}, \$jpg) == 200) {
  839. saveString('poster.jpg', $jpg);
  840. }
  841. }
  842. # Save the series NFO
  843. my $xml = buildSeriesNFO($channel);
  844. if ($DEBUG > 1) {
  845. print STDERR 'Saving NFO: ' . $xml . "\n";
  846. }
  847. saveString($nfo, $xml);
  848. }
  849. }
  850. sub getChannel($) {
  851. my ($id) = @_;
  852. our $NAME;
  853. # Build, fetch, parse, check
  854. my $data = fetchParse('channel', { 'id' => $id });
  855. if ( exists($data->{'pageInfo'})
  856. && ref($data->{'pageInfo'}) eq 'HASH'
  857. && exists($data->{'pageInfo'}->{'totalResults'})
  858. && $data->{'pageInfo'}->{'totalResults'} == 1
  859. && exists($data->{'items'})
  860. && ref($data->{'items'}) eq 'ARRAY'
  861. && scalar(@{ $data->{'items'} }) > 0
  862. && ref($data->{'items'}[0]) eq 'HASH'
  863. && exists($data->{'items'}[0]->{'snippet'})
  864. && ref($data->{'items'}[0]->{'snippet'}) eq 'HASH')
  865. {
  866. $data = $data->{'items'}[0]->{'snippet'};
  867. } else {
  868. die($NAME . ": Invalid channel data\n");
  869. }
  870. # Extract the data we want
  871. my %channel = (
  872. 'id' => $id,
  873. 'title' => $data->{'title'},
  874. 'date' => str2time($data->{'publishedAt'}),
  875. 'description' => $data->{'description'},
  876. 'thumbnail' => $data->{'thumbnails'}->{'high'}->{'url'},
  877. );
  878. return \%channel;
  879. }
  880. sub readExcludes() {
  881. my %excludes = ();
  882. our $NAME;
  883. # Read and parse the excludes videos file, if it exists
  884. if (-e $EXCLUDES_FILE) {
  885. my $fh;
  886. open($fh, $EXCLUDES_FILE)
  887. or die($NAME . ': Unable to open excludes videos file: ' . $! . "\n");
  888. while (<$fh>) {
  889. # Skip blank lines and comments
  890. if ($_ =~ /^\s*#/ || $_ =~ /^\s*$/) {
  891. next;
  892. }
  893. # Match our specific format or whine
  894. if ($_ =~ /^\s*([\w\-]+)\s*$/) {
  895. if ($DEBUG > 1) {
  896. print STDERR 'Adding exclude video: ' . $1 . "\n";
  897. }
  898. $excludes{$1} = 1;
  899. } else {
  900. print STDERR 'Skipped exclude video line: ' . $_;
  901. }
  902. }
  903. close($fh);
  904. }
  905. return \%excludes;
  906. }
  907. sub readExtras() {
  908. my %extras = ();
  909. our $NAME;
  910. # Read and parse the extra videos file, if it exists
  911. if (-e $EXTRAS_FILE) {
  912. my $fh;
  913. open($fh, $EXTRAS_FILE)
  914. or die($NAME . ': Unable to open extra videos file: ' . $! . "\n");
  915. while (<$fh>) {
  916. # Skip blank lines and comments
  917. if ($_ =~ /^\s*#/ || $_ =~ /^\s*$/) {
  918. next;
  919. }
  920. # Match our specific format or whine
  921. if ($_ =~ /^\s*([\w\-]+)\s*$/) {
  922. if ($DEBUG > 1) {
  923. print STDERR 'Adding extra video: ' . $1 . "\n";
  924. }
  925. $extras{$1} = 1;
  926. } else {
  927. print STDERR 'Skipped extra video line: ' . $_;
  928. }
  929. }
  930. close($fh);
  931. }
  932. return \%extras;
  933. }
  934. sub parseVideoData($) {
  935. my ($data) = @_;
  936. my %video = %VIDEO_FIELDS;
  937. # Sanity check
  938. if ( !exists($data->{'id'})
  939. || !exists($data->{'snippet'})
  940. || ref($data->{'snippet'}) ne 'HASH'
  941. || !exists($data->{'contentDetails'})
  942. || ref($data->{'contentDetails'}) ne 'HASH'
  943. || !exists($data->{'contentDetails'}->{'duration'}))
  944. {
  945. if ($DEBUG > 2) {
  946. print STDERR "Unable to parse video data:\n" . prettyPrint($data, "\t") . "\n";
  947. }
  948. return undef();
  949. }
  950. # Direct extraction
  951. $video{'id'} = $data->{'id'};
  952. $video{'title'} = $data->{'snippet'}->{'title'};
  953. $video{'description'} = $data->{'snippet'}->{'description'};
  954. $video{'creator'} = $data->{'snippet'}->{'channelTitle'};
  955. $video{'publishedAt'} = $data->{'snippet'}->{'publishedAt'};
  956. $video{'channelId'} = $data->{'snippet'}->{'channelId'};
  957. $video{'rawDuration'} = $data->{'contentDetails'}->{'duration'};
  958. # Convert dates and whatnot
  959. calcVideoData(\%video);
  960. return \%video;
  961. }
  962. sub calcVideoData($) {
  963. my ($video) = @_;
  964. if ($video->{'publishedAt'} && !$video->{'date'}) {
  965. $video->{'date'} = str2time($video->{'publishedAt'});
  966. }
  967. if ($video->{'date'}) {
  968. $video->{'season'} = time2str('%Y', $video->{'date'});
  969. } else {
  970. $video->{'season'} = 0;
  971. }
  972. if ($video->{'rawDuration'} =~ /PT(\d+)H(\d+)M(\d+)S/) {
  973. $video->{'duration'} = ($1 * 3600) + ($2 * 60) + $2;
  974. } elsif ($video->{'rawDuration'} =~ /PT(\d+)M(\d+)S/) {
  975. $video->{'duration'} = ($1 * 60) + $2;
  976. } elsif ($video->{'rawDuration'} =~ /PT(\d+)S/) {
  977. $video->{'duration'} = $1;
  978. }
  979. }
  980. sub dropExcludes($) {
  981. my ($videos) = @_;
  982. my $excludes = readExcludes();
  983. foreach my $id (keys(%{$excludes})) {
  984. if (!exists($videos->{$id})) {
  985. if ($DEBUG > 1) {
  986. print STDERR 'Skipping unknown excludes video: ' . $id . "\n";
  987. }
  988. next;
  989. }
  990. delete($videos->{$id});
  991. }
  992. return $excludes;
  993. }
  994. sub addExtras($) {
  995. my ($videos) = @_;
  996. my $extras = readExtras();
  997. foreach my $id (keys(%{$extras})) {
  998. if (exists($videos->{$id})) {
  999. if ($DEBUG > 1) {
  1000. print STDERR 'Skipping known extra video: ' . $id . "\n";
  1001. }
  1002. next;
  1003. }
  1004. my $video = getVideoData([$id]);
  1005. $videos->{$id} = $video->[0];
  1006. }
  1007. return $extras;
  1008. }
  1009. sub getVideoData($) {
  1010. my ($ids) = @_;
  1011. my @videos = ();
  1012. our $NAME;
  1013. my %found = ();
  1014. # Do a batch request for the entire batch of video data
  1015. my $data = fetchParse('video', { 'id' => join(',', @{$ids}) });
  1016. if ( exists($data->{'pageInfo'})
  1017. && ref($data->{'pageInfo'}) eq 'HASH'
  1018. && exists($data->{'pageInfo'}->{'totalResults'})
  1019. && exists($data->{'items'})
  1020. && ref($data->{'items'}) eq 'ARRAY')
  1021. {
  1022. if (scalar(@{$ids}) > 1 && $data->{'pageInfo'}->{'totalResults'} != scalar(@{$ids})) {
  1023. if ($DEBUG) {
  1024. die($NAME . ': Video/metadata search count mismatch: ' . $data->{'pageInfo'}->{'totalResults'} . '/' . scalar(@{$ids}) . "\n");
  1025. }
  1026. exit(0);
  1027. }
  1028. $data = $data->{'items'};
  1029. } else {
  1030. die($NAME . ": Invalid search video data\n" . prettyPrint($data, "\t") . "\n");
  1031. }
  1032. # Build each metadata record
  1033. foreach my $item (@{$data}) {
  1034. if (!exists($item->{'snippet'}) || ref($item->{'snippet'}) ne 'HASH') {
  1035. warn($NAME . ": Skipping invalid metadata item\n");
  1036. if ($DEBUG > 2) {
  1037. print STDERR prettyPrint($item, "\t") . "\n";
  1038. }
  1039. next;
  1040. }
  1041. my $video = parseVideoData($item);
  1042. if (!$video) {
  1043. die($NAME . ": Unable to parse video data\n");
  1044. }
  1045. # Allow local metadata overrides
  1046. localMetadata($video->{'id'}, $video);
  1047. # Save
  1048. $found{ $video->{'id'} } = 1;
  1049. push(@videos, $video);
  1050. }
  1051. # Load local metadata even for videos YT doesn't know anything about
  1052. foreach my $id (@{$ids}) {
  1053. if ($found{$id}) {
  1054. next;
  1055. }
  1056. my %video = %VIDEO_FIELDS;
  1057. $video{'id'} = $id;
  1058. localMetadata($id, \%video);
  1059. # Save
  1060. $found{$id} = 1;
  1061. push(@videos, \%video);
  1062. }
  1063. # Parse the video data
  1064. return \@videos;
  1065. }
  1066. sub localMetadata($$) {
  1067. my ($id, $video) = @_;
  1068. my $meta = $id . '.meta';
  1069. if (!-r $meta) {
  1070. return;
  1071. }
  1072. my $fh = undef();
  1073. open($fh, '<', $meta)
  1074. or warn('Unable to open local metadata file: ' . $meta . ': ' . $! . ". Skipping...\n");
  1075. while (<$fh>) {
  1076. if (/^\s*#/ || /^\s*$/) {
  1077. next;
  1078. }
  1079. if (/^\s*([^\s\=][^\=]*[^\s\=])\s*=\>?\s*(\S.*\S)\s*$/) {
  1080. if (exists($video->{$1})) {
  1081. if ($DEBUG) {
  1082. print STDERR 'Using local metadata line (' . $id . '): ' . $1 . ' => ' . $2 . "\n";
  1083. }
  1084. $video->{$1} = $2;
  1085. } else {
  1086. die('Invalid local metadata attribute (' . $1 . '): ' . $meta . "\n");
  1087. }
  1088. } else {
  1089. die('Invalid local metdata line (' . $meta . '): ' . chomp($_) . "\n");
  1090. }
  1091. }
  1092. close($fh);
  1093. calcVideoData($video);
  1094. }
  1095. sub findVideos($) {
  1096. my ($id) = @_;
  1097. my %videos = ();
  1098. our $NAME;
  1099. # Allow complete bypass
  1100. if ($NO_SEARCH) {
  1101. return \%videos;
  1102. }
  1103. # Loop through until we have all the entries
  1104. my $totalCount = 0;
  1105. my %params = ('channelId' => $id);
  1106. LOOP:
  1107. {
  1108. # Loop status
  1109. my $count = scalar(keys(%videos));
  1110. if ($DEBUG && $totalCount) {
  1111. my $date = $params{'publishedBefore'};
  1112. $date =~ s/T.*$//;
  1113. print STDERR 'Fetching ' . $count . ' of ' . $totalCount .
  1114. "\t" . $date .
  1115. "\t(" . int(100 * $count / $totalCount) . "%)\n";
  1116. }
  1117. # Build, fetch, parse, check
  1118. my $data = fetchParse('search', \%params);
  1119. delete($params{'pageToken'});
  1120. if ( exists($data->{'pageInfo'})
  1121. && ref($data->{'pageInfo'}) eq 'HASH'
  1122. && exists($data->{'pageInfo'}->{'totalResults'})
  1123. && exists($data->{'pageInfo'}->{'resultsPerPage'})
  1124. && exists($data->{'items'})
  1125. && ref($data->{'items'}) eq 'ARRAY')
  1126. {
  1127. if (!$totalCount) {
  1128. $totalCount = $data->{'pageInfo'}->{'totalResults'};
  1129. }
  1130. $data = $data->{'items'};
  1131. } else {
  1132. die($NAME . ": Invalid search data\n");
  1133. }
  1134. # Sanity check
  1135. if (scalar(@{$data}) < 1) {
  1136. last LOOP;
  1137. }
  1138. # Grab each video ID
  1139. my @ids = ();
  1140. foreach my $item (@{$data}) {
  1141. if (!exists($item->{'id'}) || ref($item->{'id'}) ne 'HASH' || !exists($item->{'id'}->{'videoId'})) {
  1142. warn($NAME . ": Skipping invalid video item\n");
  1143. if ($DEBUG > 2) {
  1144. print STDERR prettyPrint($data, "\t") . "\n";
  1145. }
  1146. next;
  1147. }
  1148. push(@ids, $item->{'id'}->{'videoId'});
  1149. }
  1150. # Do a batch request for the entire batch of video data
  1151. my $more = 0;
  1152. my $records = getVideoData(\@ids);
  1153. foreach my $rec (@{$records}) {
  1154. if (exists($rec->{'duration'}) && $rec->{'duration'} == 0) {
  1155. if ($DEBUG > 1) {
  1156. print STDERR 'Skipping 0-length video: ' . $rec->{'id'} . "\n";
  1157. }
  1158. next;
  1159. }
  1160. $videos{ $rec->{'id'} } = $rec;
  1161. if (!defined($params{'publishedBefore'}) || (defined($rec->{'publishedAt'}) && ($rec->{'publishedAt'} cmp $params{'publishedBefore'}) < 0)) {
  1162. $params{'publishedBefore'} = $rec->{'publishedAt'};
  1163. $more = 1;
  1164. }
  1165. }
  1166. # Loop if there are results left to fetch
  1167. if ($DEBUG > 1) {
  1168. print STDERR "more \t => ${more}\n";
  1169. print STDERR "count \t => ${count}\n";
  1170. }
  1171. if ($more && $params{'publishedBefore'} && $count <= $MAX_INDEX) {
  1172. redo LOOP;
  1173. }
  1174. }
  1175. # Sanity check/debug
  1176. {
  1177. my $count = scalar(keys(%videos));
  1178. if ($DEBUG) {
  1179. print STDERR 'Found ' . $count . " remote videos\n";
  1180. if (abs(1 - ($count / $totalCount)) > $API_ST_REL && abs($totalCount - $count) > $API_ST_ABS) {
  1181. print STDERR 'Found only ' . $count . ' of ' . $totalCount . ' (' . int(100 * $count / $totalCount) . "%) channel-API-reported remote videos.\n";
  1182. }
  1183. if ($DEBUG > 1) {
  1184. print STDERR prettyPrint(\%videos, "\t") . "\n";
  1185. }
  1186. }
  1187. }
  1188. return \%videos;
  1189. }
  1190. sub updateNFOData($$$) {
  1191. my ($file, $season, $episode) = @_;
  1192. my $nfoData = undef();
  1193. {
  1194. open(my $fh, '<', $file)
  1195. or warn('Unable to open NFO for renumber: ' . $file . ': ' . $! . "\n");
  1196. $/ = undef;
  1197. $nfoData = <$fh>;
  1198. close($fh);
  1199. }
  1200. if ($nfoData) {
  1201. $nfoData =~ s/<season>\d+<\/season>/<season>${season}<\/season>/;
  1202. $nfoData =~ s/<episode>\d+<\/episode>/<episode>${episode}<\/episode>/;
  1203. }
  1204. return $nfoData;
  1205. }
  1206. sub videoNumberStr($$) {
  1207. my ($season, $episode) = @_;
  1208. my $str = '';
  1209. if ($season == 0 && $episode =~ /^(20[012]\d)(\d{2,4})$/) {
  1210. $str = sprintf('%04d%04d', $1, $2);
  1211. } else {
  1212. $str = sprintf('%02d', $episode);
  1213. }
  1214. return $str;
  1215. }
  1216. sub videoSE($$) {
  1217. my ($season, $episode) = @_;
  1218. return sprintf('S%0dE%s - ', $season, videoNumberStr($season, $episode));
  1219. }
  1220. sub videoPath($$$$) {
  1221. my ($season, $episode, $id, $ext) = @_;
  1222. return videoSE($season, $episode) . $id . '.' . $ext;
  1223. }
  1224. sub renameVideo($$$$$$) {
  1225. my ($video, $suffix, $nfo, $id, $season, $episode) = @_;
  1226. our $NAME;
  1227. # Bail if disabled
  1228. if ($NO_RENAME) {
  1229. print STDERR 'Not renaming: ' . $video . "\n";
  1230. return;
  1231. }
  1232. # General sanity checks
  1233. if (!defined($id) || !defined($season) || !defined($episode)) {
  1234. die($NAME . ": Invalid call to renameVideo()\n\t" . $video . "\n\t" . $suffix . "\n\t" . $nfo . "\n\t" . $id . "\n\t" . $season . "\n\t" . $episode . "\n");
  1235. }
  1236. # Warning about useless calls
  1237. if (!defined($nfo) && (!defined($video) || !defined($suffix))) {
  1238. warn("No NFO or video path provided in renameVideo()\n");
  1239. return;
  1240. }
  1241. # Video
  1242. if (defined($video) && defined($suffix)) {
  1243. my $videoNew = videoPath($season, $episode, $id, $suffix);
  1244. # Sanity checks, as we do dangerous work here
  1245. if (!-r $video) {
  1246. die($NAME . ': Invalid video source in rename: ' . $video . "\n");
  1247. }
  1248. if ($video ne $videoNew) {
  1249. if ($DEBUG) {
  1250. warn('Renaming ' . $NAME . '/' . $video . ' => ' . $videoNew . "\n");
  1251. }
  1252. if (-e $videoNew) {
  1253. die($NAME . ': Rename: Target exists: ' . $videoNew . "\n");
  1254. }
  1255. if ($SUDO_CHATTR) {
  1256. system('sudo', $CHATTR, '-i', Cwd::abs_path($video));
  1257. }
  1258. rename($video, $videoNew)
  1259. or die($NAME . ': Unable to rename: ' . $video . ': ' . $! . "\n");
  1260. }
  1261. }
  1262. # NFO
  1263. if (defined($nfo)) {
  1264. my $nfoNew = videoPath($season, $episode, $id, 'nfo');
  1265. # Sanity checks, as we do dangerous work here
  1266. if (!-r $nfo) {
  1267. die($NAME . ': Invalid NFO source in rename: ' . $nfo . "\n");
  1268. }
  1269. # Parse old NFO data
  1270. my $nfoData = updateNFOData($nfo, $season, $episode)
  1271. or die($NAME . ': No NFO data found, refusing to rename: ' . $id . "\n");
  1272. # Write a new NFO and unlink the old one
  1273. if ($SUDO_CHATTR) {
  1274. system('sudo', $CHATTR, '-i', Cwd::abs_path($nfo));
  1275. }
  1276. saveString($nfoNew, $nfoData);
  1277. if ($nfo ne $nfoNew) {
  1278. if ($DEBUG) {
  1279. warn('Renaming ' . $NAME . '/' . $nfo . ' => ' . $nfoNew . "\n");
  1280. }
  1281. unlink($nfo)
  1282. or warn('Unable to delete NFO during rename: ' . $! . "\n");
  1283. }
  1284. }
  1285. }
  1286. sub parseFilename($) {
  1287. my ($file) = @_;
  1288. return $file =~ /^S(\d+)+E(\d+) - ([\w\-]+)(?:\-recode)?\.(\w\w\w)$/i;
  1289. }
  1290. # GET the requested URL, accepting encoded data and extracting the HTTP status code
  1291. sub fetch($$) {
  1292. my ($url, $data) = @_;
  1293. # Init the global LWP as needed
  1294. if (!defined($LWP)) {
  1295. $LWP = new LWP::UserAgent;
  1296. $LWP->agent($HTTP_UA);
  1297. $LWP->timeout($HTTP_TIMEOUT);
  1298. $LWP->ssl_opts({ 'verify_hostname' => $HTTP_VERIFY });
  1299. }
  1300. # Make a request
  1301. my $request = HTTP::Request->new('GET' => $url);
  1302. $request->header('Accept-Encoding' => HTTP::Message::decodable);
  1303. my $response = $LWP->request($request);
  1304. # Do something useful with the response
  1305. my $code = 400;
  1306. if ($response->status_line =~ /^(\d+)\s/) {
  1307. $code = $1;
  1308. }
  1309. ${$data} = $response->decoded_content();
  1310. return $code;
  1311. }
  1312. # Inter-operation delay
  1313. sub delay(;$) {
  1314. my $retval = 0;
  1315. my ($extra) = ($@);
  1316. if (!Scalar::Util::looks_like_number($extra)) {
  1317. $extra = 0;
  1318. }
  1319. my $sleep = rand($DELAY) + $MIN_DELAY + $extra;
  1320. if ($sleep > $FORK_DELAY) {
  1321. # Fork, signal the loop to exit in the child, and resume the parent
  1322. $retval = 1;
  1323. }
  1324. sleep($sleep);
  1325. return($retval);
  1326. }