subscribe.pl 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998
  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::Simple;
  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 PrettyPrint;
  17. # Paramters
  18. my %USERS = ('profplump' => 'kj-Ob6eYHvzo-P0UWfnQzA', 'shanda' => 'hfwMHzkPXOOFDce5hyQkTA');
  19. my $EXTRAS_FILE = 'extra_videos.ini';
  20. my $EXCLUDES_FILE = 'exclude_videos.ini';
  21. my $YTDL_BIN = $ENV{'HOME'} . '/bin/video/yt/youtube-dl';
  22. my @YTDL_ARGS = ('--force-ipv4', '--socket-timeout', '10', '--no-playlist', '--max-downloads', '1', '--age-limit', '99');
  23. my @YTDL_QUIET = ('--quiet', '--no-warnings');
  24. my $BATCH_SIZE = 50;
  25. my $MAX_INDEX = 25000;
  26. my $FETCH_LIMIT = 50;
  27. my $DRIFT_TOLERANCE = 2;
  28. my $DRIFT_FACTOR = 100.0;
  29. my $DELAY = 5;
  30. my $API_URL = 'https://gdata.youtube.com/feeds/api/';
  31. my %API = (
  32. 'search' => {
  33. 'prefix' => $API_URL . 'users/',
  34. 'suffix' => '/uploads',
  35. 'params' => {
  36. 'start-index' => 1,
  37. 'max-results' => 1,
  38. 'strict' => 1,
  39. 'v' => 2,
  40. 'alt' => 'jsonc',
  41. },
  42. },
  43. 'subscriptions' => {
  44. 'prefix' => $API_URL . 'users/',
  45. 'suffix' => '/subscriptions',
  46. 'params' => {
  47. 'start-index' => 1,
  48. 'max-results' => 1,
  49. 'strict' => 1,
  50. 'v' => 2,
  51. 'alt' => 'json',
  52. },
  53. },
  54. 'video' => {
  55. 'prefix' => $API_URL . 'videos/',
  56. 'suffix' => '',
  57. 'params' => {
  58. 'strict' => 1,
  59. 'v' => 2,
  60. 'alt' => 'jsonc'
  61. },
  62. },
  63. 'channel' => {
  64. 'prefix' => $API_URL . 'users/',
  65. 'suffix' => '',
  66. 'params' => {
  67. 'strict' => 1,
  68. 'v' => 2,
  69. 'alt' => 'json'
  70. },
  71. },
  72. );
  73. # Prototypes
  74. sub findVideos($);
  75. sub findFiles();
  76. sub findVideo($);
  77. sub buildNFO($);
  78. sub buildSeriesNFO($);
  79. sub getSubscriptions($$);
  80. sub saveSubscriptions($$);
  81. sub saveChannel($);
  82. sub getChannel($);
  83. sub fetchParse($$);
  84. sub saveString($$);
  85. sub readExcludes();
  86. sub readExtras();
  87. sub parseVideoData($);
  88. sub dropExcludes($);
  89. sub addExtras($);
  90. sub updateNFOData($$$);
  91. sub videoSE($$);
  92. sub videoPath($$$$);
  93. sub renameVideo($$$$$);
  94. # Sanity check
  95. if (scalar(@ARGV) < 1) {
  96. die('Usage: ' . basename($0) . " output_directory\n");
  97. }
  98. # Command-line parameters
  99. my ($dir) = @ARGV;
  100. $dir =~ s/\/+$//;
  101. if (!-d $dir) {
  102. die('Invalid output directory: ' . $dir . "\n");
  103. }
  104. my $user = basename($dir);
  105. if (length($user) < 1 || !($user =~ /^\w+$/)) {
  106. die('Invalid user: ' . $user . "\n");
  107. }
  108. # Move to the target directory so we can use relative paths later
  109. chdir($dir);
  110. # Environmental parameters (debug)
  111. my $DEBUG = 0;
  112. if ($ENV{'DEBUG'}) {
  113. if ($ENV{'DEBUG'} =~ /(\d+)/) {
  114. $DEBUG = $1;
  115. } else {
  116. $DEBUG = 1;
  117. }
  118. }
  119. my $NO_FETCH = 0;
  120. if ($ENV{'NO_FETCH'}) {
  121. $NO_FETCH = 1;
  122. }
  123. my $NO_NFO = 0;
  124. if ($ENV{'NO_NFO'}) {
  125. $NO_NFO = 1;
  126. }
  127. my $NO_SEARCH = 0;
  128. if ($ENV{'NO_SEARCH'}) {
  129. $NO_SEARCH = 1;
  130. }
  131. my $NO_FILES = 0;
  132. if ($ENV{'NO_FILES'}) {
  133. $NO_FILES = 1;
  134. }
  135. my $NO_CHANNEL = 0;
  136. if ($ENV{'NO_CHANNEL'}) {
  137. $NO_CHANNEL = 1;
  138. }
  139. my $NO_EXTRAS = 0;
  140. if ($ENV{'NO_EXTRAS'}) {
  141. $NO_EXTRAS = 1;
  142. }
  143. my $NO_EXCLUDES = 0;
  144. if ($ENV{'NO_EXCLUDES'}) {
  145. $NO_EXCLUDES = 1;
  146. }
  147. my $NO_RENAME = 0;
  148. if ($ENV{'NO_RDNAME'}) {
  149. $NO_RENAME = 1;
  150. }
  151. # Environmental parameters (functional)
  152. my $FORCE_RENAME = 0;
  153. if ($ENV{'FORCE_RENAME'}) {
  154. $FORCE_RENAME = 1;
  155. }
  156. my $SUDO_CHATTR = 1;
  157. if ($ENV{'NO_CHATTR'} || !can_run('sudo') || !can_run('chattr')) {
  158. $SUDO_CHATTR = 0;
  159. }
  160. if ($ENV{'MAX_INDEX'} && $ENV{'MAX_INDEX'} =~ /(\d+)/) {
  161. $MAX_INDEX = $1;
  162. }
  163. if ($ENV{'BATCH_SIZE'} && $ENV{'BATCH_SIZE'} =~ /(\d+)/) {
  164. $BATCH_SIZE = $1;
  165. }
  166. if (exists($ENV{'FETCH_LIMIT'}) && $ENV{'FETCH_LIMIT'} =~ /(\d+)/) {
  167. $FETCH_LIMIT = $1;
  168. }
  169. # Construct globals
  170. foreach my $key (keys(%API)) {
  171. if (exists($API{$key}{'params'}{'max-results'})) {
  172. $API{$key}{'params'}{'max-results'} = $BATCH_SIZE;
  173. }
  174. }
  175. if (!$DEBUG) {
  176. push(@YTDL_ARGS, @YTDL_QUIET);
  177. }
  178. # Allow use as a subscription manager
  179. if ($0 =~ /subscription/i) {
  180. my %subs = ();
  181. foreach my $user (keys(%USERS)) {
  182. my $tmp = getSubscriptions($user, $USERS{$user});
  183. foreach my $sub (keys(%{$tmp})) {
  184. if (exists($subs{$sub})) {
  185. $subs{$sub} .= ', ' . $tmp->{$sub};
  186. } else {
  187. $subs{$sub} = $tmp->{$sub};
  188. }
  189. }
  190. }
  191. saveSubscriptions($dir, \%subs);
  192. exit(0);
  193. }
  194. # Grab the channel data
  195. my $channel = {};
  196. if (!$NO_CHANNEL) {
  197. $channel = getChannel($user);
  198. saveChannel($channel);
  199. }
  200. # Find all the user's videos on YT
  201. my $videos = {};
  202. if (!$NO_SEARCH) {
  203. $videos = findVideos($user);
  204. }
  205. # Find any requested "extra" videos
  206. my $extras = {};
  207. if (!$NO_EXTRAS) {
  208. $extras = addExtras($videos);
  209. }
  210. # Drop any "excludes" videos
  211. my $excludes = {};
  212. if (!$NO_EXCLUDES) {
  213. $excludes = dropExcludes($videos);
  214. }
  215. # Calculate the episode number using the publish dates
  216. {
  217. my @byDate = sort { $videos->{$a}->{'date'} <=> $videos->{$b}->{'date'} || $a cmp $b } keys %{$videos};
  218. my $num = undef();
  219. my $lastSeason = undef();
  220. foreach my $id (@byDate) {
  221. if (!$lastSeason || $lastSeason != $videos->{$id}->{'season'}) {
  222. $num = 1;
  223. $lastSeason = $videos->{$id}->{'season'};
  224. }
  225. $videos->{$id}->{'number'} = $num++;
  226. }
  227. }
  228. # Find all existing YT files on disk
  229. my $files = {};
  230. if (!$NO_FILES) {
  231. $files = findFiles();
  232. }
  233. # Whine about unknown videos
  234. foreach my $id (keys(%{$files})) {
  235. if (!exists($videos->{$id}) && $files->{$id}->{'season'} > 0) {
  236. print STDERR 'Local video not known to YT channel (' . $user . '): ' . $id . "\n";
  237. renameVideo($files->{$id}->{'path'}, $files->{$id}->{'suffix'}, $id, 0, $files->{$id}->{'season'} . $files->{$id}->{'number'});
  238. }
  239. }
  240. # Fill in missing videos and NFOs
  241. my $fetched = 0;
  242. foreach my $id (keys(%{$videos})) {
  243. my $nfo = videoPath($videos->{$id}->{'season'}, $videos->{$id}->{'number'}, $id, 'nfo');
  244. # Warn (and optionally rename) if the video numbers drift
  245. if (
  246. exists($files->{$id})
  247. && ( $files->{$id}->{'number'} != $videos->{$id}->{'number'}
  248. || $files->{$id}->{'season'} != $videos->{$id}->{'season'})
  249. )
  250. {
  251. if ($FORCE_RENAME) {
  252. renameVideo($files->{$id}->{'path'}, $files->{$id}->{'suffix'}, $id, $videos->{$id}->{'season'}, $videos->{$id}->{'number'});
  253. } else {
  254. # Find the old NFO to avoid re-fetching
  255. $nfo = $files->{$id}->{'nfo'};
  256. # Calculate the drift magnitude
  257. my $delta = abs($files->{$id}->{'number'} - $videos->{$id}->{'number'});
  258. my $tolerance = $files->{$id}->{'number'} / $DRIFT_FACTOR;
  259. if ($tolerance < $DRIFT_TOLERANCE) {
  260. $tolerance = $DRIFT_TOLERANCE;
  261. }
  262. # Ignore small changes
  263. my $rename = 0;
  264. if ($FORCE_RENAME || $delta > $tolerance) {
  265. $rename = 1;
  266. }
  267. # Execute
  268. if ($DEBUG || $rename) {
  269. print STDERR 'Video ' . $id . ' had video number ' . $files->{$id}->{'number'} . ' but now has video number ' . $videos->{$id}->{'number'} . "\n";
  270. if ($rename) {
  271. renameVideo($files->{$id}->{'path'}, $files->{$id}->{'suffix'}, $id, $videos->{$id}->{'season'}, $videos->{$id}->{'number'});
  272. }
  273. }
  274. }
  275. }
  276. # If we haven't heard of the file, or don't have an NFO for it
  277. # Checking for the NFO allows use to resume failed downloads
  278. if (!exists($files->{$id}) || !-e $nfo) {
  279. if ($DEBUG) {
  280. print STDERR 'Fetching video: ' . $id . "\n";
  281. }
  282. # Let youtube-dl handle the URLs and downloading
  283. {
  284. my @args = ('--output', videoSE($videos->{$id}->{'season'}, $videos->{$id}->{'number'}) . '%(id)s.%(ext)s', '--', $id);
  285. my @name = ($YTDL_BIN);
  286. push(@name, @YTDL_ARGS);
  287. my @fetch = @name;
  288. push(@name, '--get-filename');
  289. push(@fetch, @args);
  290. push(@name, @args);
  291. if ($NO_FETCH) {
  292. print STDERR 'Not running: ' . join(' ', @fetch) . "\n";
  293. } else {
  294. # Count fetch attempts (even if they fail later)
  295. $fetched++;
  296. if ($FETCH_LIMIT && $fetched > $FETCH_LIMIT) {
  297. print STDERR 'Reached fetch limit (' . $FETCH_LIMIT . ') for: ' . $user . "\n";
  298. if ($DEBUG) {
  299. print STDERR "\tLocal/Remote videos at start:" . scalar(keys(%{$files})) . '/' . scalar(keys(%{$videos})) . "\n";
  300. }
  301. exit 1;
  302. }
  303. # Find the output file name
  304. if ($DEBUG > 1) {
  305. print STDERR join(' ', @name) . "\n";
  306. }
  307. sleep($DELAY);
  308. my $file = capture(EXIT_ANY, @name);
  309. if ($EXITVAL != 0) {
  310. warn('Error executing youtube-dl (name): ' . $EXITVAL . "\n");
  311. next;
  312. }
  313. $file =~ s/^\s+//;
  314. $file =~ s/\s+$//;
  315. # Sanity check
  316. if (!$file) {
  317. warn('No file name available for video: ' . $id . "\n");
  318. next;
  319. }
  320. # Download
  321. if ($DEBUG > 1) {
  322. print STDERR join(' ', @fetch) . "\n";
  323. }
  324. sleep($DELAY);
  325. my $exit = run(EXIT_ANY, @fetch);
  326. if ($exit != 0) {
  327. warn('Error executing youtube-dl for video: ' . $file . "\n");
  328. next;
  329. }
  330. # Ensure we found something useful
  331. if (-e $file . '.part') {
  332. warn('Partial download detected: ' . $file . "\n");
  333. next;
  334. }
  335. if (!-s $file) {
  336. warn('No output video file: ' . $file . "\n");
  337. next;
  338. }
  339. # Touch the file to reflect the download time rather than the upload time
  340. touch($file);
  341. }
  342. }
  343. # Build and save the XML document
  344. my $xml = buildNFO($videos->{$id});
  345. if ($DEBUG > 1) {
  346. print STDERR 'Saving NFO: ' . $xml . "\n";
  347. }
  348. if ($NO_NFO) {
  349. print STDERR "Not saving NFO\n";
  350. } else {
  351. saveString($nfo, $xml);
  352. }
  353. }
  354. }
  355. sub saveString($$) {
  356. my ($path, $str) = @_;
  357. my $fh = undef();
  358. if (!open($fh, '>', $path)) {
  359. warn('Cannot open file for writing: ' . $path . ': ' . $! . "\n");
  360. return undef();
  361. }
  362. print $fh $str;
  363. close($fh);
  364. return 1;
  365. }
  366. sub buildSeriesNFO($) {
  367. my ($channel) = @_;
  368. # Create an XML tree
  369. my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
  370. $doc->setStandalone(1);
  371. my $show = $doc->createElement('tvshow');
  372. $doc->setDocumentElement($show);
  373. my $elm;
  374. # Add data
  375. $elm = $doc->createElement('title');
  376. $elm->appendText($channel->{'title'});
  377. $show->appendChild($elm);
  378. $elm = $doc->createElement('premiered');
  379. $elm->appendText(time2str('%Y-%m-%d', $channel->{'date'}));
  380. $show->appendChild($elm);
  381. $elm = $doc->createElement('plot');
  382. $elm->appendText($channel->{'description'});
  383. $show->appendChild($elm);
  384. # Return the string
  385. return $doc->toString();
  386. }
  387. sub buildNFO($) {
  388. my ($video) = @_;
  389. # Create an XML tree
  390. my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
  391. $doc->setStandalone(1);
  392. my $show = $doc->createElement('episodedetails');
  393. $doc->setDocumentElement($show);
  394. my $elm;
  395. # Add data
  396. $elm = $doc->createElement('season');
  397. $elm->appendText($video->{'season'});
  398. $show->appendChild($elm);
  399. $elm = $doc->createElement('episode');
  400. $elm->appendText($video->{'number'});
  401. $show->appendChild($elm);
  402. if (!defined($video->{'title'})) {
  403. $video->{'title'} = 'Episode ' . $video->{'number'};
  404. }
  405. $elm = $doc->createElement('title');
  406. $elm->appendText($video->{'title'});
  407. $show->appendChild($elm);
  408. if (!defined($video->{'date'})) {
  409. $video->{'date'} = time();
  410. }
  411. $elm = $doc->createElement('aired');
  412. $elm->appendText(time2str('%Y-%m-%d', $video->{'date'}));
  413. $show->appendChild($elm);
  414. if (defined($video->{'description'})) {
  415. $elm = $doc->createElement('plot');
  416. $elm->appendText($video->{'description'});
  417. $show->appendChild($elm);
  418. }
  419. if (defined($video->{'duration'})) {
  420. $elm = $doc->createElement('runtime');
  421. $elm->appendText($video->{'duration'});
  422. $show->appendChild($elm);
  423. }
  424. if (defined($video->{'rating'})) {
  425. $elm = $doc->createElement('rating');
  426. $elm->appendText($video->{'rating'});
  427. $show->appendChild($elm);
  428. }
  429. if (defined($video->{'creator'})) {
  430. $elm = $doc->createElement('director');
  431. $elm->appendText($video->{'creator'});
  432. $show->appendChild($elm);
  433. }
  434. # Return the string
  435. return $doc->toString();
  436. }
  437. sub findFiles() {
  438. my %files = ();
  439. # Allow complete bypass
  440. if ($NO_FILES) {
  441. return \%files;
  442. }
  443. # Read the output directory
  444. my $fh = undef();
  445. opendir($fh, '.')
  446. or die('Unable to open files directory: ' . $! . "\n");
  447. while (my $file = readdir($fh)) {
  448. my ($season, $num, $id, $suffix) = $file =~ /^S(\d+)+E(\d+) - ([\w\-]+)\.(\w\w\w)$/i;
  449. if (defined($id) && length($id) > 0) {
  450. if ($suffix eq 'nfo') {
  451. next;
  452. }
  453. my %tmp = (
  454. 'season' => $season,
  455. 'number' => $num,
  456. 'suffix' => $suffix,
  457. 'path' => $file,
  458. );
  459. $tmp{'nfo'} = $tmp{'path'};
  460. $tmp{'nfo'} =~ s/\.\w\w\w$/\.nfo/;
  461. if (exists($files{$id})) {
  462. warn('Duplicate ID: ' . $id . "\n\t" . $files{$id}->{'path'} . "\n\t" . $tmp{'path'} . "\n");
  463. if (!$NO_RENAME) {
  464. # Prefer to delete MP4 files, if the suffixes differ
  465. my $del = $files{$id};
  466. if ($tmp{'suffix'} eq 'mp4' && $del->{'suffix'} ne 'mp4') {
  467. $del = \%tmp;
  468. %tmp = %{ $files{$id} };
  469. }
  470. print STDERR 'Deleting duplicate: ' . $del->{'path'} . "\n";
  471. unlink($del->{'path'});
  472. if ($del->{'nfo'} ne $tmp{'nfo'}) {
  473. unlink($del->{'nfo'});
  474. }
  475. }
  476. }
  477. $files{$id} = \%tmp;
  478. }
  479. }
  480. close($fh);
  481. if ($DEBUG) {
  482. print STDERR 'Found ' . scalar(keys(%files)) . " local videos\n";
  483. if ($DEBUG > 1) {
  484. print STDERR prettyPrint(\%files, "\t") . "\n";
  485. }
  486. }
  487. return \%files;
  488. }
  489. sub fetchParse($$) {
  490. my ($name, $id) = @_;
  491. my $url = $API{$name}{'prefix'} . $id . $API{$name}{'suffix'} . '?';
  492. foreach my $key (keys(%{ $API{$name}{'params'} })) {
  493. $url .= '&' . uri_escape($key) . '=' . uri_escape($API{$name}{'params'}{$key});
  494. }
  495. # Fetch
  496. if ($DEBUG) {
  497. print STDERR 'Fetching ' . $name . ' API URL: ' . $url . "\n";
  498. }
  499. sleep($DELAY);
  500. my $content = get($url);
  501. if (!defined($content) || length($content) < 10) {
  502. die('Invalid content from URL: ' . $url . "\n");
  503. }
  504. # Parse
  505. my $data = decode_json($content);
  506. if (!defined($data) || ref($data) ne 'HASH') {
  507. die('Invalid JSON: ' . $content . "\n");
  508. }
  509. if ($DEBUG > 2) {
  510. print STDERR "Raw JSON data:\n" . prettyPrint($data, ' ') . "\n";
  511. }
  512. return $data;
  513. }
  514. sub getSubscriptions($$) {
  515. my ($user, $id) = @_;
  516. my $index = 1;
  517. my $itemCount = undef();
  518. my %subs = ();
  519. SUBS_LOOP:
  520. {
  521. # Build, fetch, parse
  522. $API{'subscriptions'}{'params'}{'start-index'} = $index;
  523. my $data = fetchParse('subscriptions', $id);
  524. # It's all in the feed
  525. if (!exists($data->{'feed'}) || ref($data->{'feed'}) ne 'HASH') {
  526. die("Invalid subscription data\n");
  527. }
  528. $data = $data->{'feed'};
  529. # Grab the total count, so we know when to stop
  530. if (!defined($itemCount)) {
  531. if ( !exists($data->{'openSearch$totalResults'})
  532. || ref($data->{'openSearch$totalResults'}) ne 'HASH'
  533. || !exists($data->{'openSearch$totalResults'}->{'$t'}))
  534. {
  535. die("Invalid subscription feed metadata\n");
  536. }
  537. $itemCount = $data->{'openSearch$totalResults'}->{'$t'};
  538. }
  539. # Process each item
  540. if (!exists($data->{'entry'}) || ref($data->{'entry'}) ne 'ARRAY') {
  541. die("Invalid subscription entries\n");
  542. }
  543. my $items = $data->{'entry'};
  544. foreach my $item (@{$items}) {
  545. if ( ref($item) ne 'HASH'
  546. || !exists($item->{'yt$username'})
  547. || ref($item->{'yt$username'}) ne 'HASH'
  548. || !exists($item->{'yt$username'}->{'$t'}))
  549. {
  550. next;
  551. }
  552. if ($DEBUG) {
  553. print STDERR prettyPrint($item->{'yt$username'}, "\t") . "\n";
  554. }
  555. $subs{ $item->{'yt$username'}->{'$t'} } = $user;
  556. }
  557. # Loop if there are results left to fetch
  558. $index += $BATCH_SIZE;
  559. if (defined($itemCount) && $itemCount >= $index) {
  560. # But don't go past the max supported index
  561. if ($index <= $MAX_INDEX) {
  562. redo SUBS_LOOP;
  563. }
  564. }
  565. }
  566. # Return the list of subscribed usernames
  567. return \%subs;
  568. }
  569. sub saveSubscriptions($$) {
  570. my ($folder, $subs) = @_;
  571. # Check for local subscriptions missing from YT
  572. my %locals = ();
  573. my $fh = undef();
  574. opendir($fh, '.')
  575. or die('Unable to open subscriptions directory: ' . $folder . ': ' . $! . "\n");
  576. while (my $file = readdir($fh)) {
  577. # Skip dotfiles
  578. if ($file =~ /^\./) {
  579. next;
  580. }
  581. # Skip non-directories
  582. if (!-d $file) {
  583. next;
  584. }
  585. # YT has some trouble with case
  586. my $lcFile = lc($file);
  587. # Anything else should be in the list
  588. if (!$subs->{$file} && !$subs->{$lcFile}) {
  589. print STDERR 'Missing YT subscription for: ' . $file . "\n";
  590. }
  591. # Note local subscriptions
  592. $locals{$lcFile} = 1;
  593. }
  594. closedir($fh);
  595. # Check for YT subscriptions missing locally
  596. foreach my $sub (keys(%{$subs})) {
  597. if (!exists($locals{ lc($sub) })) {
  598. print STDERR 'Adding local subscription for: ' . $sub . ' (' . $subs->{$sub} . ")\n";
  599. mkdir($folder . '/' . $sub);
  600. }
  601. }
  602. }
  603. sub saveChannel($) {
  604. my ($channel) = @_;
  605. my $nfo = 'tvshow.nfo';
  606. if (!-e $nfo) {
  607. if ($DEBUG) {
  608. print STDERR 'Saving series data for: ' . $channel->{'title'} . "\n";
  609. }
  610. # Save the poster
  611. if (exists($channel->{'thumbnail'}) && length($channel->{'thumbnail'}) > 5) {
  612. my $jpg = get($channel->{'thumbnail'});
  613. saveString('poster.jpg', $jpg);
  614. }
  615. # Save the series NFO
  616. my $xml = buildSeriesNFO($channel);
  617. if ($DEBUG > 1) {
  618. print STDERR 'Saving NFO: ' . $xml . "\n";
  619. }
  620. saveString($nfo, $xml);
  621. }
  622. }
  623. sub getChannel($) {
  624. my ($user) = @_;
  625. # Build, fetch, parse
  626. my $data = fetchParse('channel', $user);
  627. if (!exists($data->{'entry'}) || ref($data->{'entry'}) ne 'HASH') {
  628. die("Invalid channel data\n");
  629. }
  630. $data = $data->{'entry'};
  631. # Extract the data we want
  632. my %channel = (
  633. 'id' => $data->{'yt$channelId'}->{'$t'},
  634. 'title' => $data->{'title'}->{'$t'},
  635. 'date' => str2time($data->{'published'}->{'$t'}),
  636. 'description' => $data->{'summary'}->{'$t'},
  637. 'thumbnail' => $data->{'media$thumbnail'}->{'url'},
  638. );
  639. return \%channel;
  640. }
  641. sub readExcludes() {
  642. my %excludes = ();
  643. # Read and parse the excludes videos file, if it exists
  644. if (-e $EXCLUDES_FILE) {
  645. my $fh;
  646. open($fh, $EXCLUDES_FILE)
  647. or die('Unable to open excludes videos file: ' . $! . "\n");
  648. while (<$fh>) {
  649. # Skip blank lines and comments
  650. if ($_ =~ /^\s*#/ || $_ =~ /^\s*$/) {
  651. next;
  652. }
  653. # Match our specific format or whine
  654. if ($_ =~ /^\s*([\w\-]+)\s*$/) {
  655. if ($DEBUG > 1) {
  656. print STDERR 'Adding exclude video: ' . $1 . "\n";
  657. }
  658. $excludes{$1} = 1;
  659. } else {
  660. print STDERR 'Skipped exclude video line: ' . $_;
  661. }
  662. }
  663. close($fh);
  664. }
  665. return \%excludes;
  666. }
  667. sub readExtras() {
  668. my %extras = ();
  669. # Read and parse the extra videos file, if it exists
  670. if (-e $EXTRAS_FILE) {
  671. my $fh;
  672. open($fh, $EXTRAS_FILE)
  673. or die('Unable to open extra videos file: ' . $! . "\n");
  674. while (<$fh>) {
  675. # Skip blank lines and comments
  676. if ($_ =~ /^\s*#/ || $_ =~ /^\s*$/) {
  677. next;
  678. }
  679. # Match our specific format or whine
  680. if ($_ =~ /^\s*(\d+)\s*[=:>]+\s*([\w\-]+)\s*$/) {
  681. if ($DEBUG > 1) {
  682. print STDERR 'Adding extra video: ' . $1 . ' => ' . $2 . "\n";
  683. }
  684. $extras{$2} = $1;
  685. } else {
  686. print STDERR 'Skipped extra video line: ' . $_;
  687. }
  688. }
  689. close($fh);
  690. }
  691. return \%extras;
  692. }
  693. sub parseVideoData($) {
  694. my ($data) = @_;
  695. my %video = (
  696. 'title' => $data->{'title'},
  697. 'date' => str2time($data->{'uploaded'}),
  698. 'description' => $data->{'description'},
  699. 'duration' => $data->{'duration'},
  700. 'rating' => $data->{'rating'},
  701. 'creator' => $data->{'uploader'},
  702. );
  703. $video{'season'} = time2str('%Y', $video{'date'});
  704. return \%video;
  705. }
  706. sub findVideo($) {
  707. my ($id) = @_;
  708. # Build, fetch, parse
  709. my $data = fetchParse('video', $id);
  710. # Validate
  711. if (!exists($data->{'data'})) {
  712. die("Invalid video list\n");
  713. }
  714. $data = $data->{'data'};
  715. # Parse the video data
  716. return parseVideoData($data);
  717. }
  718. sub dropExcludes($) {
  719. my ($videos) = @_;
  720. my $excludes = readExcludes();
  721. foreach my $id (keys(%{$excludes})) {
  722. if (!exists($videos->{$id})) {
  723. if ($DEBUG > 1) {
  724. print STDERR 'Skipping unknown excludes video: ' . $id . "\n";
  725. }
  726. next;
  727. }
  728. delete($videos->{$id});
  729. }
  730. return $excludes;
  731. }
  732. sub addExtras($) {
  733. my ($videos) = @_;
  734. my $extras = readExtras();
  735. foreach my $id (keys(%{$extras})) {
  736. if (exists($videos->{$id})) {
  737. if ($DEBUG > 1) {
  738. print STDERR 'Skipping known extra video: ' . $id . "\n";
  739. }
  740. next;
  741. }
  742. my $video = findVideo($id);
  743. $videos->{$id} = $video;
  744. }
  745. return $extras;
  746. }
  747. sub findVideos($) {
  748. my ($user) = @_;
  749. my %videos = ();
  750. # Allow complete bypass
  751. if ($NO_SEARCH) {
  752. return \%videos;
  753. }
  754. # Loop through until we have all the entries
  755. my $index = 1;
  756. my $itemCount = undef();
  757. LOOP:
  758. {
  759. # Build, fetch, parse
  760. $API{'search'}{'params'}{'start-index'} = $index;
  761. my $data = fetchParse('search', $user);
  762. # Grab the total count, so we know when to stop
  763. if (!exists($data->{'data'})) {
  764. die("Invalid video list\n");
  765. }
  766. $data = $data->{'data'};
  767. if (!defined($itemCount) && exists($data->{'totalItems'})) {
  768. $itemCount = $data->{'totalItems'};
  769. }
  770. # Process each item
  771. if (!exists($data->{'items'}) || ref($data->{'items'}) ne 'ARRAY') {
  772. die("Invalid video list\n");
  773. }
  774. my $items = $data->{'items'};
  775. my $offset = 0;
  776. foreach my $item (@{$items}) {
  777. my $video = parseVideoData($item);
  778. $videos{ $item->{'id'} } = $video;
  779. $offset++;
  780. }
  781. # Loop if there are results left to fetch
  782. $index += $BATCH_SIZE;
  783. if (defined($itemCount) && $itemCount >= $index) {
  784. # But don't go past the max supported index
  785. if ($index <= $MAX_INDEX) {
  786. redo LOOP;
  787. }
  788. }
  789. }
  790. if ($DEBUG) {
  791. print STDERR 'Found ' . scalar(keys(%videos)) . " remote videos\n";
  792. if ($DEBUG > 1) {
  793. print STDERR prettyPrint(\%videos, "\t") . "\n";
  794. }
  795. }
  796. return \%videos;
  797. }
  798. sub updateNFOData($$$) {
  799. my ($file, $season, $episode) = @_;
  800. my $nfoData = undef();
  801. {
  802. open(my $fh, '<', $file)
  803. or warn('Unable to open NFO for renumber: ' . $file . ': ' . $! . "\n");
  804. $/ = undef;
  805. $nfoData = <$fh>;
  806. close($fh);
  807. }
  808. if ($nfoData) {
  809. $nfoData =~ s/<season>\d+<\/season>/<season>${season}<\/season>/;
  810. $nfoData =~ s/<episode>\d+<\/episode>/<episode>${episode}<\/episode>/;
  811. }
  812. return $nfoData;
  813. }
  814. sub videoSE($$) {
  815. my ($season, $episode) = @_;
  816. return sprintf('S%02dE%02d - ', $season, $episode);
  817. }
  818. sub videoPath($$$$) {
  819. my ($season, $episode, $id, $ext) = @_;
  820. return videoSE($season, $episode) . $id . '.' . $ext;
  821. }
  822. sub renameVideo($$$$$) {
  823. my ($video, $suffix, $id, $season, $episode) = @_;
  824. # Bail if disabled
  825. if ($NO_RENAME) {
  826. print STDERR 'Not renaming: ' . $video . "\n";
  827. return;
  828. }
  829. # General sanity checks
  830. if (!defined($video) || !defined($suffix) || !defined($id) || !defined($season) || !defined($episode)) {
  831. die("Invalid call to renameVideo()\n\t" . $video . "\n\t" . $suffix . "\n\t" . $id . "\n\t" . $season . "\n\t" . $episode . "\n");
  832. }
  833. # We could ask for this but there are already a lot of parameters
  834. my $nfo = $video;
  835. $nfo =~ s/\.${suffix}$/\.nfo/;
  836. # Build a new path
  837. my $videoNew = videoPath($season, $episode, $id, $suffix);
  838. my $nfoNew = videoPath($season, $episode, $id, 'nfo');
  839. print STDERR 'Renaming ' . $video . ' => ' . $videoNew . "\n";
  840. # Parse old NFO data
  841. my $nfoData = updateNFOData($nfo, $season, $episode)
  842. or die('No NFO data found, refusing to rename: ' . $id . "\n");
  843. # Specific sanity checks, as we do dangerous work here
  844. if (!-r $video || !-r $nfo) {
  845. die('Invalid video or NFO sources in rename: ' . $video . '/' . $nfo);
  846. }
  847. if (-e $videoNew) {
  848. die('Rename: Target exists: ' . $videoNew . "\n");
  849. }
  850. # Rename video
  851. if ($SUDO_CHATTR) {
  852. system('sudo', 'chattr', '-i', $video);
  853. }
  854. rename($video, $videoNew)
  855. or die('Unable to rename: ' . $video . ': ' . $! . "\n");
  856. # Write a new NFO and unlink the old one
  857. saveString($nfoNew, $nfoData);
  858. if ($SUDO_CHATTR) {
  859. system('sudo', 'chattr', '-i', $nfo);
  860. }
  861. unlink($nfo)
  862. or warn('Unable to delete NFO during rename: ' . $! . "\n");
  863. }