reelsort 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -213,18 +213,17 @@ var searchMovie = async (title, year, apiKey) => {
213
213
  if (year) url.searchParams.set("year", String(year));
214
214
  try {
215
215
  const res = await fetch(url.toString());
216
- if (!res.ok) return null;
216
+ if (!res.ok) return [];
217
217
  const data = await res.json();
218
- const first = data.results[0];
219
- if (!first) return null;
220
- return {
221
- id: first.id,
222
- title: first.title,
223
- year: first.release_date ? parseInt(first.release_date.slice(0, 4)) : void 0,
224
- url: `${TMDB_WEB}/movie/${first.id}`
225
- };
218
+ return data.results.slice(0, 5).map((r) => ({
219
+ id: r.id,
220
+ title: r.title,
221
+ year: r.release_date ? parseInt(r.release_date.slice(0, 4)) : void 0,
222
+ overview: r.overview || void 0,
223
+ url: `${TMDB_WEB}/movie/${r.id}`
224
+ }));
226
225
  } catch {
227
- return null;
226
+ return [];
228
227
  }
229
228
  };
230
229
  var getEpisodeName = async (seriesId, season, episode, apiKey) => {
@@ -343,7 +342,7 @@ var add = async ({ name }) => {
343
342
  (0, import_fs3.mkdirSync)(showPath, { recursive: true });
344
343
  upsertShow(showPath, picked.id, picked.title);
345
344
  spinner_default.start();
346
- spinner_default.succeed(`added ${import_termkit2.Color.cyan.encoder(folderName)}`);
345
+ spinner_default.succeed(`added ${import_termkit2.Color.green.encoder(folderName)}`);
347
346
  spinner_default.stop();
348
347
  };
349
348
  var add_default = add;
@@ -383,17 +382,17 @@ var clean = async ({ dryRun, olderThan }) => {
383
382
  continue;
384
383
  }
385
384
  if (dryRun) {
386
- spinner_default.succeed(`[dry] would remove ${import_termkit3.Color.blue.encoder(imp.sourcePath)}`);
385
+ spinner_default.succeed(`[dry] would remove ${import_termkit3.Color.white.encoder(imp.sourcePath)}`);
387
386
  cleaned++;
388
387
  continue;
389
388
  }
390
389
  try {
391
390
  (0, import_fs4.rmSync)(imp.sourcePath, { recursive: true, force: true });
392
391
  deleteImport(imp.id);
393
- spinner_default.succeed(`removed ${import_termkit3.Color.blue.encoder(imp.sourcePath)}`);
392
+ spinner_default.succeed(`removed ${import_termkit3.Color.white.encoder(imp.sourcePath)}`);
394
393
  cleaned++;
395
394
  } catch {
396
- spinner_default.warn(`locked or inaccessible, skipped: ${import_termkit3.Color.blue.encoder(imp.sourcePath)}`);
395
+ spinner_default.warn(`locked or inaccessible, skipped: ${import_termkit3.Color.white.encoder(imp.sourcePath)}`);
397
396
  skipped++;
398
397
  }
399
398
  }
@@ -424,20 +423,20 @@ var formatEpisode = (season, episode, format = DEFAULT_EPISODE_FORMAT, double =
424
423
  };
425
424
 
426
425
  // src/actions/config.ts
427
- var DEST_TYPES = ["movie", "tv", "ps3"];
426
+ var DEST_TYPES = ["movie", "tv", "ps3", "book"];
428
427
  var sourceAdd = async ({ dir }) => {
429
428
  const resolved = (0, import_path4.resolve)(dir);
430
429
  const config = getConfig();
431
430
  if (config.sources.includes(resolved)) {
432
431
  spinner_default.start();
433
- spinner_default.info(`source already configured: ${import_termkit4.Color.blue.encoder(resolved)}`);
432
+ spinner_default.info(`source already configured: ${import_termkit4.Color.white.encoder(resolved)}`);
434
433
  spinner_default.stop();
435
434
  return;
436
435
  }
437
436
  config.sources.push(resolved);
438
437
  saveConfig(config);
439
438
  spinner_default.start();
440
- spinner_default.succeed(`added source: ${import_termkit4.Color.blue.encoder(resolved)}`);
439
+ spinner_default.succeed(`added source: ${import_termkit4.Color.white.encoder(resolved)}`);
441
440
  spinner_default.stop();
442
441
  };
443
442
  var sourceRemove = async ({ dir }) => {
@@ -446,14 +445,14 @@ var sourceRemove = async ({ dir }) => {
446
445
  const index = config.sources.indexOf(resolved);
447
446
  if (index === -1) {
448
447
  spinner_default.start();
449
- spinner_default.warn(`source not found: ${import_termkit4.Color.blue.encoder(resolved)}`);
448
+ spinner_default.warn(`source not found: ${import_termkit4.Color.white.encoder(resolved)}`);
450
449
  spinner_default.stop();
451
450
  return;
452
451
  }
453
452
  config.sources.splice(index, 1);
454
453
  saveConfig(config);
455
454
  spinner_default.start();
456
- spinner_default.succeed(`removed source: ${import_termkit4.Color.blue.encoder(resolved)}`);
455
+ spinner_default.succeed(`removed source: ${import_termkit4.Color.white.encoder(resolved)}`);
457
456
  spinner_default.stop();
458
457
  };
459
458
  var destAdd = async ({ type, dir }) => {
@@ -465,7 +464,7 @@ var destAdd = async ({ type, dir }) => {
465
464
  config.dest[type] = resolved;
466
465
  saveConfig(config);
467
466
  spinner_default.start();
468
- spinner_default.succeed(`set ${type} destination: ${import_termkit4.Color.cyan.encoder(resolved)}`);
467
+ spinner_default.succeed(`set ${type} destination: ${import_termkit4.Color.green.encoder(resolved)}`);
469
468
  spinner_default.stop();
470
469
  };
471
470
  var destRemove = async ({ type }) => {
@@ -491,7 +490,7 @@ var configSet = async ({ key, subkey, value }) => {
491
490
  config.language = subkey;
492
491
  saveConfig(config);
493
492
  spinner_default.start();
494
- spinner_default.succeed(`set subtitle language: ${import_termkit4.Color.cyan.encoder(subkey)}`);
493
+ spinner_default.succeed(`set subtitle language: ${import_termkit4.Color.green.encoder(subkey)}`);
495
494
  spinner_default.stop();
496
495
  return;
497
496
  }
@@ -521,7 +520,7 @@ var configSet = async ({ key, subkey, value }) => {
521
520
  }
522
521
  saveConfig(config);
523
522
  spinner_default.start();
524
- spinner_default.succeed(`set ${subkey} format: ${import_termkit4.Color.cyan.encoder(value ?? subkey)}`);
523
+ spinner_default.succeed(`set ${subkey} format: ${import_termkit4.Color.green.encoder(value ?? subkey)}`);
525
524
  spinner_default.stop();
526
525
  return;
527
526
  }
@@ -533,7 +532,7 @@ var configShow = async () => {
533
532
  if (config.sources.length === 0) {
534
533
  console.log(" (none)");
535
534
  } else {
536
- for (const s of config.sources) console.log(` ${import_termkit4.Color.blue.encoder(s)}`);
535
+ for (const s of config.sources) console.log(` ${import_termkit4.Color.white.encoder(s)}`);
537
536
  }
538
537
  console.log("\nDestinations:");
539
538
  const entries = DEST_TYPES.map((t) => ({ type: t, path: config.dest[t] })).filter((e) => e.path);
@@ -541,15 +540,15 @@ var configShow = async () => {
541
540
  console.log(" (none)");
542
541
  } else {
543
542
  for (const { type, path } of entries) {
544
- console.log(` ${type.padEnd(6)} ${import_termkit4.Color.cyan.encoder(path)}`);
543
+ console.log(` ${type.padEnd(6)} ${import_termkit4.Color.green.encoder(path)}`);
545
544
  }
546
545
  }
547
546
  console.log(`
548
- Subtitle language: ${import_termkit4.Color.cyan.encoder(config.language ?? "eng (default)")}`);
547
+ Subtitle language: ${import_termkit4.Color.green.encoder(config.language ?? "eng (default)")}`);
549
548
  console.log(`TMDb API key: ${config.tmdbApiKey ? import_termkit4.Color.green.encoder("configured") : import_termkit4.Color.red.encoder("not set")}`);
550
- console.log(`Movie format: ${import_termkit4.Color.cyan.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
551
- console.log(`Episode format: ${import_termkit4.Color.cyan.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
552
- console.log(`Season folder: ${import_termkit4.Color.cyan.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
549
+ console.log(`Movie format: ${import_termkit4.Color.green.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
550
+ console.log(`Episode format: ${import_termkit4.Color.green.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
551
+ console.log(`Season folder: ${import_termkit4.Color.green.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
553
552
  console.log();
554
553
  };
555
554
 
@@ -560,7 +559,7 @@ var import_termkit5 = require("termkit");
560
559
  var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
561
560
  let dir1 = rawDir1;
562
561
  let dir2 = rawDir2;
563
- spinner_default.text = `checking differences between ${import_termkit5.Color.blue.encoder(dir1)} and ${import_termkit5.Color.blue.encoder(dir2)}`;
562
+ spinner_default.text = `checking differences between ${import_termkit5.Color.white.encoder(dir1)} and ${import_termkit5.Color.white.encoder(dir2)}`;
564
563
  spinner_default.start();
565
564
  dir1 = (0, import_path5.resolve)(dir1);
566
565
  dir2 = (0, import_path5.resolve)(dir2);
@@ -596,7 +595,7 @@ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
596
595
  removed.push(l);
597
596
  }
598
597
  }
599
- spinner_default.succeed(`checked differences between ${import_termkit5.Color.blue.encoder(dir1)} and ${import_termkit5.Color.blue.encoder(dir2)}`);
598
+ spinner_default.succeed(`checked differences between ${import_termkit5.Color.white.encoder(dir1)} and ${import_termkit5.Color.white.encoder(dir2)}`);
600
599
  spinner_default.succeed(`found ${added.length} added files`);
601
600
  spinner_default.succeed(`found ${removed.length} removed files`);
602
601
  spinner_default.stop();
@@ -627,9 +626,9 @@ var ended = async ({ remove }) => {
627
626
  setShowEnded(picked.path, !remove);
628
627
  spinner_default.start();
629
628
  if (remove) {
630
- spinner_default.succeed(`marked as active: ${import_termkit6.Color.cyan.encoder(picked.label)}`);
629
+ spinner_default.succeed(`marked as active: ${import_termkit6.Color.green.encoder(picked.label)}`);
631
630
  } else {
632
- spinner_default.succeed(`marked as ended: ${import_termkit6.Color.cyan.encoder(picked.label)}`);
631
+ spinner_default.succeed(`marked as ended: ${import_termkit6.Color.green.encoder(picked.label)}`);
633
632
  }
634
633
  spinner_default.stop();
635
634
  };
@@ -652,8 +651,8 @@ var history = async ({ limit, imports }) => {
652
651
  ${import_termkit7.Color.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
653
652
  for (const r of session.records) {
654
653
  const src = (0, import_path6.basename)(r.sourcePath);
655
- const dest = import_termkit7.Color.cyan.encoder(r.destinationPath);
656
- const mode = r.mode !== "move" ? ` ${import_termkit7.Color.blue.encoder(`[${r.mode}]`)}` : "";
654
+ const dest = import_termkit7.Color.green.encoder(r.destinationPath);
655
+ const mode = r.mode !== "move" ? ` ${import_termkit7.Color.white.encoder(`[${r.mode}]`)}` : "";
657
656
  console.log(` ${src} \u2192 ${dest}${mode}`);
658
657
  }
659
658
  }
@@ -672,7 +671,7 @@ ${import_termkit7.Color.yellow.encoder(label)} (${folders.length} item${folders
672
671
  for (const r of folders) {
673
672
  const oldName = (0, import_path6.basename)(r.oldPath);
674
673
  const newName = (0, import_path6.basename)(r.newPath);
675
- console.log(` ${import_termkit7.Color.blue.encoder(oldName)} \u2192 ${import_termkit7.Color.cyan.encoder(newName)}`);
674
+ console.log(` ${import_termkit7.Color.white.encoder(oldName)} \u2192 ${import_termkit7.Color.green.encoder(newName)}`);
676
675
  }
677
676
  }
678
677
  }
@@ -710,7 +709,7 @@ var link = async ({ force }) => {
710
709
  skipped++;
711
710
  continue;
712
711
  }
713
- spinner_default.start(`linking ${import_termkit8.Color.blue.encoder(show)}`);
712
+ spinner_default.start(`linking ${import_termkit8.Color.white.encoder(show)}`);
714
713
  const title = parseShowTitle(show);
715
714
  const results = await searchTv(title, config.tmdbApiKey);
716
715
  if (results.length === 0) {
@@ -720,7 +719,7 @@ var link = async ({ force }) => {
720
719
  }
721
720
  if (results.length === 1) {
722
721
  upsertShow(showPath, results[0].id, results[0].title);
723
- spinner_default.succeed(`${show} \u2192 ${import_termkit8.Color.cyan.encoder(results[0].title)} (${results[0].year})`);
722
+ spinner_default.succeed(`${show} \u2192 ${import_termkit8.Color.green.encoder(results[0].title)} (${results[0].year})`);
724
723
  linked++;
725
724
  continue;
726
725
  }
@@ -735,7 +734,7 @@ var link = async ({ force }) => {
735
734
  spinner_default.start();
736
735
  if (picked) {
737
736
  upsertShow(showPath, picked.id, picked.title);
738
- spinner_default.succeed(`${show} \u2192 ${import_termkit8.Color.cyan.encoder(picked.title)} (${picked.year})`);
737
+ spinner_default.succeed(`${show} \u2192 ${import_termkit8.Color.green.encoder(picked.title)} (${picked.year})`);
739
738
  linked++;
740
739
  } else {
741
740
  spinner_default.info(`skipped: ${show}`);
@@ -892,7 +891,7 @@ var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter
892
891
  const destRoot = config.dest[t];
893
892
  if (!(0, import_fs8.existsSync)(destRoot)) {
894
893
  console.log(`
895
- ${t.toUpperCase()} ${import_termkit9.Color.blue.encoder(destRoot)} (not found)`);
894
+ ${t.toUpperCase()} ${import_termkit9.Color.white.encoder(destRoot)} (not found)`);
896
895
  continue;
897
896
  }
898
897
  const folders = (0, import_fs8.readdirSync)(destRoot).filter((f) => {
@@ -923,7 +922,7 @@ ${t.toUpperCase()} ${import_termkit9.Color.blue.encoder(destRoot)} (not found)
923
922
  return yearDiff !== 0 ? yearDiff : a.title.localeCompare(b.title);
924
923
  });
925
924
  console.log(`
926
- ${import_termkit9.Color.yellow.encoder(t.toUpperCase())} ${import_termkit9.Color.blue.encoder(destRoot)}`);
925
+ ${import_termkit9.Color.yellow.encoder(t.toUpperCase())} ${import_termkit9.Color.white.encoder(destRoot)}`);
927
926
  new import_termkit9.Table(
928
927
  filtered.map((e) => ({
929
928
  title: e.title,
@@ -1113,7 +1112,7 @@ var probe = async ({ type, force, verbose }) => {
1113
1112
  for (const t of types) {
1114
1113
  const destRoot = config.dest[t];
1115
1114
  if (!(0, import_fs10.existsSync)(destRoot)) continue;
1116
- spinner_default.text = `scanning ${import_termkit11.Color.blue.encoder(destRoot)}`;
1115
+ spinner_default.text = `scanning ${import_termkit11.Color.white.encoder(destRoot)}`;
1117
1116
  const files = walkVideoFiles(destRoot);
1118
1117
  for (const filePath of files) {
1119
1118
  if (!force && getMediaInfo(filePath)) {
@@ -1121,7 +1120,7 @@ var probe = async ({ type, force, verbose }) => {
1121
1120
  skipped++;
1122
1121
  continue;
1123
1122
  }
1124
- spinner_default.text = `probing ${import_termkit11.Color.blue.encoder(filePath)}`;
1123
+ spinner_default.text = `probing ${import_termkit11.Color.white.encoder(filePath)}`;
1125
1124
  const result = runFfprobe(filePath);
1126
1125
  if (!result) {
1127
1126
  if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
@@ -1205,13 +1204,13 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1205
1204
  const config = getConfig();
1206
1205
  const language = config.language ?? "eng";
1207
1206
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1208
- spinner_default.text = `renaming in ${import_termkit12.Color.blue.encoder(dir)}`;
1207
+ spinner_default.text = `renaming in ${import_termkit12.Color.white.encoder(dir)}`;
1209
1208
  spinner_default.start();
1210
1209
  if (!(0, import_fs11.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
1211
1210
  const list2 = (0, import_fs11.readdirSync)(dir);
1212
1211
  let renamed = 0, removed = 0, skipped = 0;
1213
1212
  for (const [index, entry] of list2.entries()) {
1214
- spinner_default.text = `renaming in ${import_termkit12.Color.blue.encoder(dir)} ${index + 1}/${list2.length}`;
1213
+ spinner_default.text = `renaming in ${import_termkit12.Color.white.encoder(dir)} ${index + 1}/${list2.length}`;
1215
1214
  if (!(0, import_fs11.lstatSync)((0, import_path12.resolve)(dir, entry)).isDirectory()) {
1216
1215
  if (verbose) spinner_default.info(`skipped ${entry}`);
1217
1216
  skipped++;
@@ -1296,7 +1295,7 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1296
1295
  spinner_default.succeed(`renamed ${renamed} files`);
1297
1296
  if (removed) spinner_default.info(`removed ${removed} files`);
1298
1297
  spinner_default.info(`skipped ${skipped} files`);
1299
- spinner_default.succeed(`done in ${import_termkit12.Color.cyan.encoder(dir)}`);
1298
+ spinner_default.succeed(`done in ${import_termkit12.Color.green.encoder(dir)}`);
1300
1299
  spinner_default.stop();
1301
1300
  };
1302
1301
  var rename_default = rename;
@@ -1307,7 +1306,7 @@ var import_path13 = require("path");
1307
1306
  var import_termkit13 = require("termkit");
1308
1307
  var reset = async ({ dir: inputDir, double }) => {
1309
1308
  let dir = inputDir;
1310
- spinner_default.text = `resetting episodes in ${import_termkit13.Color.blue.encoder(dir)}`;
1309
+ spinner_default.text = `resetting episodes in ${import_termkit13.Color.white.encoder(dir)}`;
1311
1310
  spinner_default.start();
1312
1311
  dir = (0, import_path13.resolve)(dir);
1313
1312
  if (!(0, import_fs12.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
@@ -1336,7 +1335,7 @@ var reset = async ({ dir: inputDir, double }) => {
1336
1335
  const episodeFormat = getConfig().format?.episode;
1337
1336
  let renamed = 0, skipped = other.length;
1338
1337
  for (const [index, i] of sublist.entries()) {
1339
- spinner_default.text = `resetting episodes in ${import_termkit13.Color.blue.encoder(dir)} ${index}/${list2.length}`;
1338
+ spinner_default.text = `resetting episodes in ${import_termkit13.Color.white.encoder(dir)} ${index}/${list2.length}`;
1340
1339
  const ext = i.match(/([^.]+$)/)?.[0];
1341
1340
  const episode = double ? index * 2 + 1 : index + 1;
1342
1341
  const name = `${formatEpisode(seasonNum, episode, episodeFormat, double, showTitle)}.${ext}`;
@@ -1349,7 +1348,7 @@ var reset = async ({ dir: inputDir, double }) => {
1349
1348
  }
1350
1349
  spinner_default.succeed(`renamed ${renamed} files`);
1351
1350
  spinner_default.info(`skipped ${skipped} files`);
1352
- spinner_default.succeed(`done in ${import_termkit13.Color.cyan.encoder(dir)}`);
1351
+ spinner_default.succeed(`done in ${import_termkit13.Color.green.encoder(dir)}`);
1353
1352
  spinner_default.stop();
1354
1353
  };
1355
1354
  var reset_default = reset;
@@ -1417,6 +1416,9 @@ var parseDownloadName = (name) => {
1417
1416
  return { title: titleCase_default(titleTokens.join(" ")), year, type: "movie" };
1418
1417
  };
1419
1418
 
1419
+ // src/refs/bookExtensions.json
1420
+ var bookExtensions_default = ["epub", "mobi", "azw3", "azw"];
1421
+
1420
1422
  // src/actions/scan.ts
1421
1423
  var sameDev = (a, b) => {
1422
1424
  try {
@@ -1439,6 +1441,99 @@ var findVideo = (dir) => (0, import_fs13.readdirSync)(dir).find((f) => {
1439
1441
  const ext = f.match(/([^.]+$)/)?.[0];
1440
1442
  return ext && videoExtensions_default.includes(ext);
1441
1443
  }) ?? null;
1444
+ var containsBook = (dir, depth = 2) => (0, import_fs13.readdirSync)(dir).some((f) => {
1445
+ const ext = f.match(/([^.]+$)/)?.[0];
1446
+ if (ext && bookExtensions_default.includes(ext)) return true;
1447
+ if (depth > 1) {
1448
+ try {
1449
+ const sub = (0, import_path14.resolve)(dir, f);
1450
+ if ((0, import_fs13.lstatSync)(sub).isDirectory()) return containsBook(sub, depth - 1);
1451
+ } catch {
1452
+ }
1453
+ }
1454
+ return false;
1455
+ });
1456
+ var isTvEpisodeName = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
1457
+ var isSeasonDirName = (name) => !isTvEpisodeName(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
1458
+ var gatherEntries = (source) => {
1459
+ const result = [];
1460
+ for (const name of (0, import_fs13.readdirSync)(source)) {
1461
+ const fullPath = (0, import_path14.resolve)(source, name);
1462
+ let isDir;
1463
+ try {
1464
+ isDir = (0, import_fs13.lstatSync)(fullPath).isDirectory();
1465
+ } catch {
1466
+ continue;
1467
+ }
1468
+ const ext = name.match(/([^.]+$)/)?.[0];
1469
+ const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1470
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1471
+ if (!isDir && !isVideo && !isBook) continue;
1472
+ if (!isDir) {
1473
+ result.push({ entry: name, entryPath: fullPath, isDir: false });
1474
+ continue;
1475
+ }
1476
+ if (/(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name) || isTvEpisodeName(name)) {
1477
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1478
+ continue;
1479
+ }
1480
+ let children;
1481
+ try {
1482
+ children = (0, import_fs13.readdirSync)(fullPath);
1483
+ } catch {
1484
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1485
+ continue;
1486
+ }
1487
+ if (children.some((c) => isTvEpisodeName(c))) {
1488
+ for (const child of children) {
1489
+ const childPath = (0, import_path14.resolve)(fullPath, child);
1490
+ let childIsDir;
1491
+ try {
1492
+ childIsDir = (0, import_fs13.lstatSync)(childPath).isDirectory();
1493
+ } catch {
1494
+ continue;
1495
+ }
1496
+ const childExt = child.match(/([^.]+$)/)?.[0];
1497
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1498
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1499
+ }
1500
+ continue;
1501
+ }
1502
+ const seasonDirs = children.filter((c) => {
1503
+ try {
1504
+ return isSeasonDirName(c) && (0, import_fs13.lstatSync)((0, import_path14.resolve)(fullPath, c)).isDirectory();
1505
+ } catch {
1506
+ return false;
1507
+ }
1508
+ });
1509
+ if (seasonDirs.length > 0) {
1510
+ for (const seasonDir of seasonDirs) {
1511
+ const seasonPath = (0, import_path14.resolve)(fullPath, seasonDir);
1512
+ let seasonChildren;
1513
+ try {
1514
+ seasonChildren = (0, import_fs13.readdirSync)(seasonPath);
1515
+ } catch {
1516
+ continue;
1517
+ }
1518
+ for (const child of seasonChildren) {
1519
+ const childPath = (0, import_path14.resolve)(seasonPath, child);
1520
+ let childIsDir;
1521
+ try {
1522
+ childIsDir = (0, import_fs13.lstatSync)(childPath).isDirectory();
1523
+ } catch {
1524
+ continue;
1525
+ }
1526
+ const childExt = child.match(/([^.]+$)/)?.[0];
1527
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1528
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1529
+ }
1530
+ }
1531
+ continue;
1532
+ }
1533
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1534
+ }
1535
+ return result;
1536
+ };
1442
1537
  var findSeasonFolder = (showPath, season) => {
1443
1538
  if (!(0, import_fs13.existsSync)(showPath)) return null;
1444
1539
  const folders = (0, import_fs13.readdirSync)(showPath).filter((f) => {
@@ -1453,35 +1548,130 @@ var findSeasonFolder = (showPath, season) => {
1453
1548
  return match && parseInt(match[1]) === season;
1454
1549
  }) ?? null;
1455
1550
  };
1456
- var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1551
+ var classifyMovieConfidence = (entry) => {
1552
+ if (/^\[(?:Team|Group)\s/i.test(entry)) return "skip";
1553
+ if (/\bepisodes?\s+\d+[-–]\d+/i.test(entry)) return "skip";
1554
+ if (/\b(?:patch|keygen|crack)\b|\bkeys?\s*\{/i.test(entry)) return "skip";
1555
+ if (/\[YTS[.\-]/i.test(entry)) return "auto";
1556
+ if (/\(\d{4}\)/.test(entry) && /\[(?:2160p|1080p|720p|480p|576p|BluRay|BDRip|BDRemux|WEBRip|WEB-DL|HDRip|DVDRip|HDTV)/i.test(entry)) return "auto";
1557
+ if (/\.\d{4}\..*?(?:2160p|1080p|720p|BluRay|WEBRip|WEB-DL|HDTV)/i.test(entry)) return "auto";
1558
+ return "ambiguous";
1559
+ };
1560
+ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, interactive }) => {
1457
1561
  const config = getConfig();
1458
1562
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1459
1563
  const language = config.language ?? "eng";
1460
1564
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1461
1565
  const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
1566
+ const lookupMovie = async (parsed) => {
1567
+ let tmdbId;
1568
+ let resolvedTitle = parsed.title;
1569
+ let resolvedYear = parsed.year;
1570
+ if (config.tmdbApiKey) {
1571
+ const results = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1572
+ if (results.length === 1) {
1573
+ tmdbId = results[0].id;
1574
+ resolvedTitle = results[0].title;
1575
+ resolvedYear = results[0].year ?? parsed.year;
1576
+ } else if (results.length > 1) {
1577
+ spinner_default.stop();
1578
+ const select = new import_termkit14.Select();
1579
+ const items = results.map((r) => ({
1580
+ label: r.year ? `${r.title} (${r.year})` : r.title,
1581
+ description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
1582
+ ...r
1583
+ }));
1584
+ const picked = await select.ask(`Multiple movies found for "${parsed.title}":`, items);
1585
+ spinner_default.start();
1586
+ if (picked) {
1587
+ tmdbId = picked.id;
1588
+ resolvedTitle = picked.title;
1589
+ resolvedYear = picked.year ?? parsed.year;
1590
+ }
1591
+ }
1592
+ }
1593
+ return { tmdbId, resolvedTitle, resolvedYear };
1594
+ };
1595
+ const importMovie = async (entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot) => {
1596
+ const edition = detectEdition(entry);
1597
+ const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1598
+ const destFolder = (0, import_path14.resolve)(destRoot, folderName);
1599
+ if ((0, import_fs13.existsSync)(destFolder)) {
1600
+ spinner_default.warn(`already exists: ${folderName}`);
1601
+ return false;
1602
+ }
1603
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1604
+ if (!videoFile) {
1605
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1606
+ return false;
1607
+ }
1608
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1609
+ const destVideoName = `${folderName}.${videoExt}`;
1610
+ const videoSourcePath = isDir ? (0, import_path14.resolve)(entryPath, videoFile) : entryPath;
1611
+ const dirFiles = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
1612
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1613
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1614
+ const subtitleSourcePath = subtitle ? (0, import_path14.resolve)(entryPath, subtitle) : null;
1615
+ const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1616
+ if (!dryRun) {
1617
+ if (useHardlink) {
1618
+ (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1619
+ const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1620
+ let mode;
1621
+ try {
1622
+ if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1623
+ (0, import_fs13.linkSync)(videoSourcePath, destVideoPath);
1624
+ mode = "hardlink";
1625
+ } catch {
1626
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1627
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1628
+ mode = "copy";
1629
+ }
1630
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs13.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(destFolder, destSubtitleName));
1631
+ recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1632
+ } else {
1633
+ if (isDir) {
1634
+ const keep = new Set([videoFile, subtitle].filter(Boolean));
1635
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) (0, import_fs13.rmSync)((0, import_path14.resolve)(entryPath, f), { recursive: true, force: true });
1636
+ (0, import_fs13.renameSync)(videoSourcePath, (0, import_path14.resolve)(entryPath, destVideoName));
1637
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs13.renameSync)(subtitleSourcePath, (0, import_path14.resolve)(entryPath, destSubtitleName));
1638
+ moveFolder(entryPath, destFolder);
1639
+ } else {
1640
+ (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1641
+ const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1642
+ if (sameDev(videoSourcePath, destRoot)) {
1643
+ (0, import_fs13.renameSync)(videoSourcePath, destVideoPath);
1644
+ } else {
1645
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1646
+ (0, import_fs13.rmSync)(videoSourcePath);
1647
+ }
1648
+ }
1649
+ recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1650
+ }
1651
+ }
1652
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1653
+ return true;
1654
+ };
1462
1655
  spinner_default.start();
1463
1656
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1464
1657
  let imported = 0, skipped = 0;
1658
+ const pendingMovies = [];
1465
1659
  for (const source of config.sources) {
1466
1660
  if (!(0, import_fs13.existsSync)(source)) {
1467
- spinner_default.warn(`source not found: ${import_termkit14.Color.blue.encoder(source)}`);
1661
+ spinner_default.warn(`source not found: ${import_termkit14.Color.white.encoder(source)}`);
1468
1662
  continue;
1469
1663
  }
1470
- spinner_default.text = `scanning ${import_termkit14.Color.blue.encoder(source)}`;
1471
- for (const entry of (0, import_fs13.readdirSync)(source)) {
1472
- const entryPath = (0, import_path14.resolve)(source, entry);
1473
- const isDir = (0, import_fs13.lstatSync)(entryPath).isDirectory();
1664
+ spinner_default.text = `scanning ${import_termkit14.Color.white.encoder(source)}`;
1665
+ for (const { entry, entryPath, isDir } of gatherEntries(source)) {
1474
1666
  const ext = entry.match(/([^.]+$)/)?.[0];
1475
- const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1476
- if (!isDir && !isVideo) {
1477
- if (verbose) spinner_default.info(`skipped ${entry}`);
1478
- skipped++;
1479
- continue;
1480
- }
1667
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1668
+ const isBookDir = isDir && containsBook(entryPath);
1481
1669
  let detectedType;
1482
1670
  if (type) {
1483
1671
  detectedType = type;
1484
- } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1672
+ } else if (isBook || isBookDir) {
1673
+ detectedType = "book";
1674
+ } else if (isDir && /^[A-Z]{4}\d{5}/i.test(entry)) {
1485
1675
  detectedType = "ps3";
1486
1676
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1487
1677
  detectedType = "tv";
@@ -1516,12 +1706,49 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1516
1706
  imported++;
1517
1707
  continue;
1518
1708
  }
1709
+ if (detectedType === "book") {
1710
+ const destPath = (0, import_path14.resolve)(destRoot, entry);
1711
+ if ((0, import_fs13.existsSync)(destPath)) {
1712
+ spinner_default.warn(`already exists: ${entry}`);
1713
+ skipped++;
1714
+ continue;
1715
+ }
1716
+ if (!dryRun) {
1717
+ if (isDir || isBookDir) {
1718
+ moveFolder(entryPath, destPath);
1719
+ } else {
1720
+ (0, import_fs13.mkdirSync)(destRoot, { recursive: true });
1721
+ if (sameDev(entryPath, destRoot)) {
1722
+ (0, import_fs13.renameSync)(entryPath, destPath);
1723
+ } else {
1724
+ (0, import_fs13.cpSync)(entryPath, destPath);
1725
+ (0, import_fs13.rmSync)(entryPath);
1726
+ }
1727
+ }
1728
+ recordImport(sessionId, entryPath, destPath, "move");
1729
+ }
1730
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${entry}`);
1731
+ imported++;
1732
+ continue;
1733
+ }
1519
1734
  const parsed = parseDownloadName(entry);
1520
1735
  if (!parsed) {
1521
1736
  if (verbose) spinner_default.info(`could not parse: ${entry}`);
1522
1737
  skipped++;
1523
1738
  continue;
1524
1739
  }
1740
+ if (detectedType === "movie") {
1741
+ const confidence = classifyMovieConfidence(entry);
1742
+ if (confidence === "skip") {
1743
+ if (verbose) spinner_default.info(`skipped (uncertain): ${entry}`);
1744
+ skipped++;
1745
+ continue;
1746
+ }
1747
+ if (confidence === "ambiguous") {
1748
+ pendingMovies.push({ entry, entryPath, isDir, parsed, destRoot });
1749
+ continue;
1750
+ }
1751
+ }
1525
1752
  let tmdbId;
1526
1753
  let resolvedTitle = parsed.title;
1527
1754
  let resolvedYear = parsed.year;
@@ -1549,12 +1776,10 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1549
1776
  }
1550
1777
  }
1551
1778
  } else {
1552
- const tmdb = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1553
- if (tmdb) {
1554
- tmdbId = tmdb.id;
1555
- resolvedTitle = tmdb.title;
1556
- resolvedYear = tmdb.year ?? parsed.year;
1557
- }
1779
+ const result = await lookupMovie(parsed);
1780
+ tmdbId = result.tmdbId;
1781
+ resolvedTitle = result.resolvedTitle;
1782
+ resolvedYear = result.resolvedYear;
1558
1783
  }
1559
1784
  }
1560
1785
  if (detectedType === "tv") {
@@ -1580,50 +1805,68 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1580
1805
  }
1581
1806
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1582
1807
  const seasonPath = (0, import_path14.resolve)(showPath, seasonFolderName);
1583
- const videoFile2 = isDir ? findVideo(entryPath) : entry;
1584
- if (!videoFile2) {
1808
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1809
+ if (!videoFile) {
1585
1810
  if (verbose) spinner_default.info(`no video found in: ${entry}`);
1586
1811
  skipped++;
1587
1812
  continue;
1588
1813
  }
1589
- const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
1814
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1590
1815
  const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1591
1816
  const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, resolvedTitle, tmdbEpisodeName ?? void 0);
1592
- const destVideoName2 = `${episodeName}.${videoExt2}`;
1593
- const destVideoPath = (0, import_path14.resolve)(seasonPath, destVideoName2);
1594
- const videoSourcePath2 = isDir ? (0, import_path14.resolve)(entryPath, videoFile2) : entryPath;
1817
+ const destVideoName = `${episodeName}.${videoExt}`;
1818
+ const destVideoPath = (0, import_path14.resolve)(seasonPath, destVideoName);
1819
+ const videoSourcePath = isDir ? (0, import_path14.resolve)(entryPath, videoFile) : entryPath;
1595
1820
  if ((0, import_fs13.existsSync)(destVideoPath)) {
1596
- spinner_default.warn(`already exists: ${episodeName}`);
1597
- skipped++;
1598
- continue;
1821
+ let shouldReplace = force;
1822
+ if (!shouldReplace && interactive) {
1823
+ spinner_default.stop();
1824
+ const select = new import_termkit14.Select();
1825
+ const picked = await select.ask(`Already exists \u2014 replace?`, [
1826
+ { label: `${showFolderName} / ${seasonFolderName} / ${episodeName}`, value: "replace" },
1827
+ { label: "Skip", value: "skip" }
1828
+ ]);
1829
+ spinner_default.start();
1830
+ shouldReplace = picked?.value === "replace";
1831
+ }
1832
+ if (!shouldReplace) {
1833
+ spinner_default.warn(`already exists: ${episodeName}`);
1834
+ skipped++;
1835
+ continue;
1836
+ }
1837
+ if (!dryRun) {
1838
+ for (const f of (0, import_fs13.readdirSync)(seasonPath)) {
1839
+ if (f.startsWith(`${episodeName}.`)) (0, import_fs13.rmSync)((0, import_path14.resolve)(seasonPath, f));
1840
+ }
1841
+ }
1599
1842
  }
1600
- const dirFiles2 = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
1601
- const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
1602
- const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
1603
- const subtitleSourcePath2 = subtitle2 ? (0, import_path14.resolve)(entryPath, subtitle2) : null;
1604
- const destSubtitleName2 = subtitle2 && subtitleExt2 ? `${episodeName}.${subtitleExt2}` : null;
1843
+ const dirFiles = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
1844
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1845
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1846
+ const subtitleSourcePath = subtitle ? (0, import_path14.resolve)(entryPath, subtitle) : null;
1847
+ const destSubtitleName = subtitle && subtitleExt ? `${episodeName}.${subtitleExt}` : null;
1605
1848
  if (!dryRun) {
1606
1849
  (0, import_fs13.mkdirSync)(seasonPath, { recursive: true });
1607
1850
  let mode = "move";
1608
1851
  if (useHardlink) {
1609
1852
  try {
1610
- if (!sameDev(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1611
- (0, import_fs13.linkSync)(videoSourcePath2, destVideoPath);
1853
+ if (!sameDev(videoSourcePath, seasonPath)) throw new Error("cross-filesystem");
1854
+ (0, import_fs13.linkSync)(videoSourcePath, destVideoPath);
1612
1855
  mode = "hardlink";
1613
1856
  } catch {
1614
1857
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1615
- (0, import_fs13.cpSync)(videoSourcePath2, destVideoPath);
1858
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1616
1859
  mode = "copy";
1617
1860
  }
1618
- if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs13.cpSync)(subtitleSourcePath2, (0, import_path14.resolve)(seasonPath, destSubtitleName2));
1861
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs13.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(seasonPath, destSubtitleName));
1619
1862
  } else {
1620
- if (sameDev(videoSourcePath2, seasonPath)) {
1621
- (0, import_fs13.renameSync)(videoSourcePath2, destVideoPath);
1863
+ if (sameDev(videoSourcePath, seasonPath)) {
1864
+ (0, import_fs13.renameSync)(videoSourcePath, destVideoPath);
1622
1865
  } else {
1623
- (0, import_fs13.cpSync)(videoSourcePath2, destVideoPath);
1624
- (0, import_fs13.rmSync)(videoSourcePath2);
1866
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1867
+ (0, import_fs13.rmSync)(videoSourcePath);
1625
1868
  }
1626
- if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs13.renameSync)(subtitleSourcePath2, (0, import_path14.resolve)(seasonPath, destSubtitleName2));
1869
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs13.renameSync)(subtitleSourcePath, (0, import_path14.resolve)(seasonPath, destSubtitleName));
1627
1870
  if (isDir) (0, import_fs13.rmSync)(entryPath, { recursive: true, force: true });
1628
1871
  }
1629
1872
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
@@ -1632,69 +1875,43 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1632
1875
  imported++;
1633
1876
  continue;
1634
1877
  }
1635
- const edition = detectEdition(entry);
1636
- const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1637
- const destFolder = (0, import_path14.resolve)(destRoot, folderName);
1638
- if ((0, import_fs13.existsSync)(destFolder)) {
1639
- spinner_default.warn(`already exists: ${folderName}`);
1878
+ if (await importMovie(entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot)) {
1879
+ imported++;
1880
+ } else {
1640
1881
  skipped++;
1641
- continue;
1642
1882
  }
1643
- const videoFile = isDir ? findVideo(entryPath) : entry;
1644
- if (!videoFile) {
1645
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1883
+ }
1884
+ }
1885
+ if (pendingMovies.length > 0) {
1886
+ spinner_default.warn(`${pendingMovies.length} uncertain movie match${pendingMovies.length > 1 ? "es" : ""} skipped \u2014 use -i to review or -f to import all`);
1887
+ for (const p of pendingMovies) spinner_default.info(` ? ${p.entry.replace(/\/$/, "")}`);
1888
+ let toProcess = [];
1889
+ if (interactive) {
1890
+ spinner_default.stop();
1891
+ const ms = new import_termkit14.MultiSelect({ allowSkip: true, search: true, maxHeight: 20 });
1892
+ const items = pendingMovies.map((p) => ({
1893
+ label: p.entry.replace(/\/$/, ""),
1894
+ description: p.parsed.year ? `parsed: ${p.parsed.title} \xB7 ${p.parsed.year}` : `parsed: ${p.parsed.title}`,
1895
+ ...p
1896
+ }));
1897
+ toProcess = await ms.ask("Select entries to import as movies:", items) ?? [];
1898
+ spinner_default.start();
1899
+ skipped += pendingMovies.length - toProcess.length;
1900
+ } else if (force) {
1901
+ toProcess = pendingMovies;
1902
+ } else {
1903
+ skipped += pendingMovies.length;
1904
+ }
1905
+ for (const p of toProcess) {
1906
+ const { tmdbId, resolvedTitle, resolvedYear } = await lookupMovie(p.parsed);
1907
+ if (await importMovie(p.entry, p.entryPath, p.isDir, resolvedTitle, resolvedYear, tmdbId, p.destRoot)) {
1908
+ imported++;
1909
+ } else {
1646
1910
  skipped++;
1647
- continue;
1648
- }
1649
- const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1650
- const destVideoName = `${folderName}.${videoExt}`;
1651
- const videoSourcePath = isDir ? (0, import_path14.resolve)(entryPath, videoFile) : entryPath;
1652
- const dirFiles = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
1653
- const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1654
- const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1655
- const subtitleSourcePath = subtitle ? (0, import_path14.resolve)(entryPath, subtitle) : null;
1656
- const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1657
- if (!dryRun) {
1658
- if (useHardlink) {
1659
- (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1660
- const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1661
- let mode;
1662
- try {
1663
- if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1664
- (0, import_fs13.linkSync)(videoSourcePath, destVideoPath);
1665
- mode = "hardlink";
1666
- } catch {
1667
- spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1668
- (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1669
- mode = "copy";
1670
- }
1671
- if (subtitleSourcePath && destSubtitleName) (0, import_fs13.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(destFolder, destSubtitleName));
1672
- recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1673
- } else {
1674
- if (isDir) {
1675
- const keep = new Set([videoFile, subtitle].filter(Boolean));
1676
- for (const f of dirFiles.filter((f2) => !keep.has(f2))) (0, import_fs13.rmSync)((0, import_path14.resolve)(entryPath, f), { recursive: true, force: true });
1677
- (0, import_fs13.renameSync)(videoSourcePath, (0, import_path14.resolve)(entryPath, destVideoName));
1678
- if (subtitleSourcePath && destSubtitleName) (0, import_fs13.renameSync)(subtitleSourcePath, (0, import_path14.resolve)(entryPath, destSubtitleName));
1679
- moveFolder(entryPath, destFolder);
1680
- } else {
1681
- (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1682
- const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1683
- if (sameDev(videoSourcePath, destRoot)) {
1684
- (0, import_fs13.renameSync)(videoSourcePath, destVideoPath);
1685
- } else {
1686
- (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1687
- (0, import_fs13.rmSync)(videoSourcePath);
1688
- }
1689
- }
1690
- recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1691
- }
1692
1911
  }
1693
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1694
- imported++;
1695
1912
  }
1696
1913
  }
1697
- spinner_default.succeed(`imported ${imported} items`);
1914
+ spinner_default.succeed(`${dryRun ? "would import" : "imported"} ${imported} items`);
1698
1915
  if (skipped) spinner_default.info(`skipped ${skipped} items`);
1699
1916
  spinner_default.stop();
1700
1917
  };
@@ -1714,7 +1931,7 @@ var shows = async () => {
1714
1931
  return;
1715
1932
  }
1716
1933
  console.log(`
1717
- ${import_termkit15.Color.yellow.encoder("SHOWS")}${destRoot ? ` ${import_termkit15.Color.blue.encoder(destRoot)}` : ""} (${allShows.length} registered)`);
1934
+ ${import_termkit15.Color.yellow.encoder("SHOWS")}${destRoot ? ` ${import_termkit15.Color.white.encoder(destRoot)}` : ""} (${allShows.length} registered)`);
1718
1935
  new import_termkit15.Table(
1719
1936
  allShows.map((show) => ({
1720
1937
  name: show.path.split("/").pop() ?? show.path,
@@ -1828,7 +2045,7 @@ var undo = async () => {
1828
2045
  let undone = 0;
1829
2046
  for (const record of records) {
1830
2047
  (0, import_fs16.renameSync)(record.newPath, record.oldPath);
1831
- spinner_default.succeed(`${import_termkit17.Color.cyan.encoder(record.newPath)} \u2192 ${import_termkit17.Color.blue.encoder(record.oldPath)}`);
2048
+ spinner_default.succeed(`${import_termkit17.Color.green.encoder(record.newPath)} \u2192 ${import_termkit17.Color.white.encoder(record.oldPath)}`);
1832
2049
  undone++;
1833
2050
  }
1834
2051
  deleteSession(records[0].sessionId);
@@ -1863,6 +2080,86 @@ var findVideo2 = (dir) => (0, import_fs17.readdirSync)(dir).find((f) => {
1863
2080
  const ext = f.match(/([^.]+$)/)?.[0];
1864
2081
  return ext && videoExtensions_default.includes(ext);
1865
2082
  }) ?? null;
2083
+ var containsBook2 = (dir, depth = 2) => (0, import_fs17.readdirSync)(dir).some((f) => {
2084
+ const ext = f.match(/([^.]+$)/)?.[0];
2085
+ if (ext && bookExtensions_default.includes(ext)) return true;
2086
+ if (depth > 1) {
2087
+ try {
2088
+ const sub = (0, import_path16.resolve)(dir, f);
2089
+ if ((0, import_fs17.lstatSync)(sub).isDirectory()) return containsBook2(sub, depth - 1);
2090
+ } catch {
2091
+ }
2092
+ }
2093
+ return false;
2094
+ });
2095
+ var isTvEpisodeName2 = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
2096
+ var isSeasonDirName2 = (name) => !isTvEpisodeName2(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
2097
+ var expandWatchPath = (p) => {
2098
+ let isDir;
2099
+ try {
2100
+ isDir = (0, import_fs17.lstatSync)(p).isDirectory();
2101
+ } catch {
2102
+ return [p];
2103
+ }
2104
+ if (!isDir) return [p];
2105
+ const name = (0, import_path16.basename)(p);
2106
+ if (isTvEpisodeName2(name) || /(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name)) return [p];
2107
+ let children;
2108
+ try {
2109
+ children = (0, import_fs17.readdirSync)(p);
2110
+ } catch {
2111
+ return [p];
2112
+ }
2113
+ if (children.some((c) => isTvEpisodeName2(c))) {
2114
+ const entries = [];
2115
+ for (const child of children) {
2116
+ const cp = (0, import_path16.resolve)(p, child);
2117
+ let cd;
2118
+ try {
2119
+ cd = (0, import_fs17.lstatSync)(cp).isDirectory();
2120
+ } catch {
2121
+ continue;
2122
+ }
2123
+ const ext = child.match(/([^.]+$)/)?.[0];
2124
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
2125
+ entries.push(cp);
2126
+ }
2127
+ return entries.length > 0 ? entries : [p];
2128
+ }
2129
+ const seasonDirs = children.filter((c) => {
2130
+ try {
2131
+ return isSeasonDirName2(c) && (0, import_fs17.lstatSync)((0, import_path16.resolve)(p, c)).isDirectory();
2132
+ } catch {
2133
+ return false;
2134
+ }
2135
+ });
2136
+ if (seasonDirs.length > 0) {
2137
+ const entries = [];
2138
+ for (const sd of seasonDirs) {
2139
+ const sp = (0, import_path16.resolve)(p, sd);
2140
+ let sc;
2141
+ try {
2142
+ sc = (0, import_fs17.readdirSync)(sp);
2143
+ } catch {
2144
+ continue;
2145
+ }
2146
+ for (const child of sc) {
2147
+ const cp = (0, import_path16.resolve)(sp, child);
2148
+ let cd;
2149
+ try {
2150
+ cd = (0, import_fs17.lstatSync)(cp).isDirectory();
2151
+ } catch {
2152
+ continue;
2153
+ }
2154
+ const ext = child.match(/([^.]+$)/)?.[0];
2155
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
2156
+ entries.push(cp);
2157
+ }
2158
+ }
2159
+ return entries.length > 0 ? entries : [p];
2160
+ }
2161
+ return [p];
2162
+ };
1866
2163
  var findSeasonFolder2 = (showPath, season) => {
1867
2164
  if (!(0, import_fs17.existsSync)(showPath)) return null;
1868
2165
  const folders = (0, import_fs17.readdirSync)(showPath).filter((f) => {
@@ -1886,9 +2183,13 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1886
2183
  const isDir = (0, import_fs17.lstatSync)(entryPath).isDirectory();
1887
2184
  const ext = entry.match(/([^.]+$)/)?.[0];
1888
2185
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1889
- if (!isDir && !isVideo) return;
2186
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
2187
+ const isBookDir = isDir && containsBook2(entryPath);
2188
+ if (!isDir && !isVideo && !isBook) return;
1890
2189
  let detectedType;
1891
- if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
2190
+ if (isBook || isBookDir) {
2191
+ detectedType = "book";
2192
+ } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1892
2193
  detectedType = "ps3";
1893
2194
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1894
2195
  detectedType = "tv";
@@ -1912,7 +2213,28 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1912
2213
  }
1913
2214
  moveItem(entryPath, destPath);
1914
2215
  recordImport(sessionId, entryPath, destPath, "move");
1915
- spinner_default.succeed(`imported ${import_termkit18.Color.cyan.encoder(destName)}`);
2216
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(destName)}`);
2217
+ return;
2218
+ }
2219
+ if (detectedType === "book") {
2220
+ const destPath = (0, import_path16.resolve)(destRoot, entry);
2221
+ if ((0, import_fs17.existsSync)(destPath)) {
2222
+ spinner_default.warn(`already exists: ${entry}`);
2223
+ return;
2224
+ }
2225
+ if (isDir || isBookDir) {
2226
+ moveItem(entryPath, destPath);
2227
+ } else {
2228
+ (0, import_fs17.mkdirSync)(destRoot, { recursive: true });
2229
+ if (sameDev2(entryPath, destRoot)) {
2230
+ (0, import_fs17.renameSync)(entryPath, destPath);
2231
+ } else {
2232
+ (0, import_fs17.cpSync)(entryPath, destPath);
2233
+ (0, import_fs17.rmSync)(entryPath);
2234
+ }
2235
+ }
2236
+ recordImport(sessionId, entryPath, destPath, "move");
2237
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(entry)}`);
1916
2238
  return;
1917
2239
  }
1918
2240
  const parsed = parseDownloadName(entry);
@@ -1985,7 +2307,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1985
2307
  if (isDir) (0, import_fs17.rmSync)(entryPath, { recursive: true, force: true });
1986
2308
  }
1987
2309
  recordImport(sessionId, entryPath, seasonPath, mode);
1988
- spinner_default.succeed(`imported ${import_termkit18.Color.cyan.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
2310
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1989
2311
  return;
1990
2312
  }
1991
2313
  const edition = detectEdition(entry);
@@ -2042,7 +2364,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2042
2364
  }
2043
2365
  recordImport(sessionId, entryPath, destFolder, "move");
2044
2366
  }
2045
- spinner_default.succeed(`imported ${import_termkit18.Color.cyan.encoder(folderName)}`);
2367
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(folderName)}`);
2046
2368
  };
2047
2369
  var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2048
2370
  const config = getConfig();
@@ -2057,7 +2379,9 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2057
2379
  setTimeout(async () => {
2058
2380
  pending.delete(path);
2059
2381
  try {
2060
- await processItem(path, hardlink, verbose, language, auto);
2382
+ for (const entry of expandWatchPath(path)) {
2383
+ await processItem(entry, hardlink, verbose, language, auto);
2384
+ }
2061
2385
  } catch (err) {
2062
2386
  spinner_default.fail(`error processing ${path}: ${err.message}`);
2063
2387
  }
@@ -2073,31 +2397,35 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2073
2397
  watcher.on("add", handle);
2074
2398
  spinner_default.start();
2075
2399
  spinner_default.succeed(`watching ${config.sources.length} source${config.sources.length !== 1 ? "s" : ""}`);
2076
- for (const s of config.sources) spinner_default.info(` ${import_termkit18.Color.blue.encoder(s)}`);
2400
+ for (const s of config.sources) spinner_default.info(` ${import_termkit18.Color.white.encoder(s)}`);
2077
2401
  spinner_default.stop();
2078
2402
  process.stdin.resume();
2079
2403
  };
2080
2404
  var watch_default = watch;
2081
2405
 
2082
2406
  // package.json
2083
- var version = "0.2.0";
2407
+ var version = "0.2.2";
2084
2408
 
2085
2409
  // src/program.ts
2086
- var adapt = (fn) => (options) => fn(options);
2410
+ var toCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2411
+ var adapt = (fn) => (options) => {
2412
+ const camel = Object.fromEntries(Object.entries(options).map(([k, v]) => [toCamel(k), v]));
2413
+ return fn(camel);
2414
+ };
2087
2415
  var { command, option } = import_termkit19.Program;
2088
2416
  var program = import_termkit19.Program.command("reelsort").version(version).description("a cli to manage media").commands([
2089
2417
  command("config").description("manage configuration").commands([
2090
2418
  command("source").description("manage source directories").commands([command("add", "<dir>").description("add a source directory").action(adapt(sourceAdd)), command("remove", "<dir>").description("remove a source directory").action(adapt(sourceRemove))]),
2091
- command("dest").description("manage destinations").commands([command("add", "<type> <dir>").description("set a destination (movie, tv, ps3)").action(adapt(destAdd)), command("remove", "<type>").description("remove a destination (movie, tv, ps3)").action(adapt(destRemove))]),
2419
+ command("dest").description("manage destinations").commands([command("add", "<type> <dir>").description("set a destination (movie, tv, ps3, book)").action(adapt(destAdd)), command("remove", "<type>").description("remove a destination (movie, tv, ps3, book)").action(adapt(destRemove))]),
2092
2420
  command("set", "<key> <subkey> [value]").description("set a value (e.g. set language eng)").action(adapt(configSet)),
2093
2421
  command("show").description("show current configuration").action(adapt(configShow))
2094
2422
  ]),
2095
- command("rename", "<dir>").description("rename media files in directory").options([option("t", "type", "<type>", "media type: movie, tv, ps3"), option("v", "verbose", null, "additional output")]).action(adapt(rename_default)),
2423
+ command("rename", "<dir>").description("rename media files in directory").options([option("t", "type", "<type>", "media type: movie, tv, ps3, book"), option("v", "verbose", null, "additional output")]).action(adapt(rename_default)),
2096
2424
  command("reset", "<dir>").description("reset episode names (sxee)").options([option("d", "double", null, "episodes are doubles")]).action(adapt(reset_default)),
2097
- command("probe").description("index library metadata using ffprobe").options([option("t", "type", "<type>", "media type: movie, tv, ps3"), option("f", "force", null, "re-probe files already indexed"), option("v", "verbose", null, "show each file as it is probed")]).action(adapt(probe_default)),
2098
- command("list").description("list library contents").options([option("t", "type", "<type>", "media type: movie, tv, ps3"), option("m", "missing-subs", null, "only show items without subtitles"), option("c", "codec", "<codec>", "filter by codec (e.g. x265)"), option("r", "resolution", "<res>", "filter by resolution (e.g. 1080p, 4K)"), option("s", "sort", "<field>", "sort by: year (default), title")]).action(adapt(list_default)),
2425
+ command("probe").description("index library metadata using ffprobe").options([option("t", "type", "<type>", "media type: movie, tv, ps3, book"), option("f", "force", null, "re-probe files already indexed"), option("v", "verbose", null, "show each file as it is probed")]).action(adapt(probe_default)),
2426
+ command("list").description("list library contents").options([option("t", "type", "<type>", "media type: movie, tv, ps3, book"), option("m", "missing-subs", null, "only show items without subtitles"), option("c", "codec", "<codec>", "filter by codec (e.g. x265)"), option("r", "resolution", "<res>", "filter by resolution (e.g. 1080p, 4K)"), option("s", "sort", "<field>", "sort by: year (default), title")]).action(adapt(list_default)),
2099
2427
  command("watch").description("watch sources and auto-import new media").options([option("H", "hardlink", null, "hardlink instead of moving (falls back to copy across filesystems)"), option("v", "verbose", null, "additional output"), option("a", "auto", null, "auto-register unrecognised TV shows instead of skipping them")]).action(adapt(watch_default)),
2100
- command("scan").description("import media from configured sources to destinations").options([option("t", "type", "<type>", "only process this media type: movie, tv, ps3"), option("H", "hardlink", null, "hardlink instead of moving (falls back to copy across filesystems)"), option("n", "dry-run", null, "show what would be imported without doing it"), option("v", "verbose", null, "additional output"), option("a", "auto", null, "auto-register unrecognised TV shows instead of skipping them")]).action(adapt(scan_default)),
2428
+ command("scan").description("import media from configured sources to destinations").options([option("t", "type", "<type>", "only process this media type: movie, tv, ps3, book"), option("H", "hardlink", null, "hardlink instead of moving (falls back to copy across filesystems)"), option("n", "dry-run", null, "show what would be imported without doing it"), option("v", "verbose", null, "additional output"), option("a", "auto", null, "auto-register unrecognised TV shows instead of skipping them"), option("f", "force", null, "overwrite existing files and import all uncertain movie matches without prompting"), option("i", "interactive", null, "review uncertain movie matches and existing-file conflicts with an interactive picker")]).action(adapt(scan_default)),
2101
2429
  command("clean").description("remove source files that have already been imported").options([option("n", "dry-run", null, "show what would be removed without doing it"), option("o", "older-than", "<age>", "only clean imports older than age (e.g. 14d, 6h, 30m)")]).action(adapt(clean_default)),
2102
2430
  command("undo").description("undo the last rename session").action(adapt(undo_default)),
2103
2431
  command("history").description("show rename or import history").options([option("l", "limit", "<n>", "number of sessions to show (default 10)"), option("i", "imports", null, "show import history instead of rename history")]).action(adapt(history_default)),