reelsort 0.2.0 → 0.2.1

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,10 @@ 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) => (0, import_fs13.readdirSync)(dir).some((f) => {
1445
+ const ext = f.match(/([^.]+$)/)?.[0];
1446
+ return ext && bookExtensions_default.includes(ext);
1447
+ });
1442
1448
  var findSeasonFolder = (showPath, season) => {
1443
1449
  if (!(0, import_fs13.existsSync)(showPath)) return null;
1444
1450
  const folders = (0, import_fs13.readdirSync)(showPath).filter((f) => {
@@ -1453,27 +1459,128 @@ var findSeasonFolder = (showPath, season) => {
1453
1459
  return match && parseInt(match[1]) === season;
1454
1460
  }) ?? null;
1455
1461
  };
1456
- var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1462
+ var classifyMovieConfidence = (entry) => {
1463
+ if (/^\[(?:Team|Group)\s/i.test(entry)) return "skip";
1464
+ if (/\bepisodes?\s+\d+[-–]\d+/i.test(entry)) return "skip";
1465
+ if (/\b(?:patch|keygen|crack)\b|\bkeys?\s*\{/i.test(entry)) return "skip";
1466
+ if (/\[YTS[.\-]/i.test(entry)) return "auto";
1467
+ if (/\(\d{4}\)/.test(entry) && /\[(?:2160p|1080p|720p|480p|576p|BluRay|BDRip|BDRemux|WEBRip|WEB-DL|HDRip|DVDRip|HDTV)/i.test(entry)) return "auto";
1468
+ if (/\.\d{4}\..*?(?:2160p|1080p|720p|BluRay|WEBRip|WEB-DL|HDTV)/i.test(entry)) return "auto";
1469
+ return "ambiguous";
1470
+ };
1471
+ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, interactive }) => {
1457
1472
  const config = getConfig();
1458
1473
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1459
1474
  const language = config.language ?? "eng";
1460
1475
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1461
1476
  const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
1477
+ const lookupMovie = async (parsed) => {
1478
+ let tmdbId;
1479
+ let resolvedTitle = parsed.title;
1480
+ let resolvedYear = parsed.year;
1481
+ if (config.tmdbApiKey) {
1482
+ const results = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1483
+ if (results.length === 1) {
1484
+ tmdbId = results[0].id;
1485
+ resolvedTitle = results[0].title;
1486
+ resolvedYear = results[0].year ?? parsed.year;
1487
+ } else if (results.length > 1) {
1488
+ spinner_default.stop();
1489
+ const select = new import_termkit14.Select();
1490
+ const items = results.map((r) => ({
1491
+ label: r.year ? `${r.title} (${r.year})` : r.title,
1492
+ description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
1493
+ ...r
1494
+ }));
1495
+ const picked = await select.ask(`Multiple movies found for "${parsed.title}":`, items);
1496
+ spinner_default.start();
1497
+ if (picked) {
1498
+ tmdbId = picked.id;
1499
+ resolvedTitle = picked.title;
1500
+ resolvedYear = picked.year ?? parsed.year;
1501
+ }
1502
+ }
1503
+ }
1504
+ return { tmdbId, resolvedTitle, resolvedYear };
1505
+ };
1506
+ const importMovie = async (entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot) => {
1507
+ const edition = detectEdition(entry);
1508
+ const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1509
+ const destFolder = (0, import_path14.resolve)(destRoot, folderName);
1510
+ if ((0, import_fs13.existsSync)(destFolder)) {
1511
+ spinner_default.warn(`already exists: ${folderName}`);
1512
+ return false;
1513
+ }
1514
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1515
+ if (!videoFile) {
1516
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1517
+ return false;
1518
+ }
1519
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1520
+ const destVideoName = `${folderName}.${videoExt}`;
1521
+ const videoSourcePath = isDir ? (0, import_path14.resolve)(entryPath, videoFile) : entryPath;
1522
+ const dirFiles = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
1523
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1524
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1525
+ const subtitleSourcePath = subtitle ? (0, import_path14.resolve)(entryPath, subtitle) : null;
1526
+ const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1527
+ if (!dryRun) {
1528
+ if (useHardlink) {
1529
+ (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1530
+ const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1531
+ let mode;
1532
+ try {
1533
+ if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1534
+ (0, import_fs13.linkSync)(videoSourcePath, destVideoPath);
1535
+ mode = "hardlink";
1536
+ } catch {
1537
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1538
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1539
+ mode = "copy";
1540
+ }
1541
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs13.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(destFolder, destSubtitleName));
1542
+ recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1543
+ } else {
1544
+ if (isDir) {
1545
+ const keep = new Set([videoFile, subtitle].filter(Boolean));
1546
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) (0, import_fs13.rmSync)((0, import_path14.resolve)(entryPath, f), { recursive: true, force: true });
1547
+ (0, import_fs13.renameSync)(videoSourcePath, (0, import_path14.resolve)(entryPath, destVideoName));
1548
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs13.renameSync)(subtitleSourcePath, (0, import_path14.resolve)(entryPath, destSubtitleName));
1549
+ moveFolder(entryPath, destFolder);
1550
+ } else {
1551
+ (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1552
+ const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1553
+ if (sameDev(videoSourcePath, destRoot)) {
1554
+ (0, import_fs13.renameSync)(videoSourcePath, destVideoPath);
1555
+ } else {
1556
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1557
+ (0, import_fs13.rmSync)(videoSourcePath);
1558
+ }
1559
+ }
1560
+ recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1561
+ }
1562
+ }
1563
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1564
+ return true;
1565
+ };
1462
1566
  spinner_default.start();
1463
1567
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1464
1568
  let imported = 0, skipped = 0;
1569
+ const pendingMovies = [];
1465
1570
  for (const source of config.sources) {
1466
1571
  if (!(0, import_fs13.existsSync)(source)) {
1467
- spinner_default.warn(`source not found: ${import_termkit14.Color.blue.encoder(source)}`);
1572
+ spinner_default.warn(`source not found: ${import_termkit14.Color.white.encoder(source)}`);
1468
1573
  continue;
1469
1574
  }
1470
- spinner_default.text = `scanning ${import_termkit14.Color.blue.encoder(source)}`;
1575
+ spinner_default.text = `scanning ${import_termkit14.Color.white.encoder(source)}`;
1471
1576
  for (const entry of (0, import_fs13.readdirSync)(source)) {
1472
1577
  const entryPath = (0, import_path14.resolve)(source, entry);
1473
1578
  const isDir = (0, import_fs13.lstatSync)(entryPath).isDirectory();
1474
1579
  const ext = entry.match(/([^.]+$)/)?.[0];
1475
1580
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1476
- if (!isDir && !isVideo) {
1581
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1582
+ const isBookDir = isDir && containsBook(entryPath);
1583
+ if (!isDir && !isVideo && !isBook) {
1477
1584
  if (verbose) spinner_default.info(`skipped ${entry}`);
1478
1585
  skipped++;
1479
1586
  continue;
@@ -1481,6 +1588,8 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1481
1588
  let detectedType;
1482
1589
  if (type) {
1483
1590
  detectedType = type;
1591
+ } else if (isBook || isBookDir) {
1592
+ detectedType = "book";
1484
1593
  } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1485
1594
  detectedType = "ps3";
1486
1595
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
@@ -1516,12 +1625,49 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1516
1625
  imported++;
1517
1626
  continue;
1518
1627
  }
1628
+ if (detectedType === "book") {
1629
+ const destPath = (0, import_path14.resolve)(destRoot, entry);
1630
+ if ((0, import_fs13.existsSync)(destPath)) {
1631
+ spinner_default.warn(`already exists: ${entry}`);
1632
+ skipped++;
1633
+ continue;
1634
+ }
1635
+ if (!dryRun) {
1636
+ if (isDir || isBookDir) {
1637
+ moveFolder(entryPath, destPath);
1638
+ } else {
1639
+ (0, import_fs13.mkdirSync)(destRoot, { recursive: true });
1640
+ if (sameDev(entryPath, destRoot)) {
1641
+ (0, import_fs13.renameSync)(entryPath, destPath);
1642
+ } else {
1643
+ (0, import_fs13.cpSync)(entryPath, destPath);
1644
+ (0, import_fs13.rmSync)(entryPath);
1645
+ }
1646
+ }
1647
+ recordImport(sessionId, entryPath, destPath, "move");
1648
+ }
1649
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${entry}`);
1650
+ imported++;
1651
+ continue;
1652
+ }
1519
1653
  const parsed = parseDownloadName(entry);
1520
1654
  if (!parsed) {
1521
1655
  if (verbose) spinner_default.info(`could not parse: ${entry}`);
1522
1656
  skipped++;
1523
1657
  continue;
1524
1658
  }
1659
+ if (detectedType === "movie") {
1660
+ const confidence = classifyMovieConfidence(entry);
1661
+ if (confidence === "skip") {
1662
+ if (verbose) spinner_default.info(`skipped (uncertain): ${entry}`);
1663
+ skipped++;
1664
+ continue;
1665
+ }
1666
+ if (confidence === "ambiguous") {
1667
+ pendingMovies.push({ entry, entryPath, isDir, parsed, destRoot });
1668
+ continue;
1669
+ }
1670
+ }
1525
1671
  let tmdbId;
1526
1672
  let resolvedTitle = parsed.title;
1527
1673
  let resolvedYear = parsed.year;
@@ -1549,12 +1695,10 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1549
1695
  }
1550
1696
  }
1551
1697
  } 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
- }
1698
+ const result = await lookupMovie(parsed);
1699
+ tmdbId = result.tmdbId;
1700
+ resolvedTitle = result.resolvedTitle;
1701
+ resolvedYear = result.resolvedYear;
1558
1702
  }
1559
1703
  }
1560
1704
  if (detectedType === "tv") {
@@ -1580,50 +1724,68 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1580
1724
  }
1581
1725
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1582
1726
  const seasonPath = (0, import_path14.resolve)(showPath, seasonFolderName);
1583
- const videoFile2 = isDir ? findVideo(entryPath) : entry;
1584
- if (!videoFile2) {
1727
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1728
+ if (!videoFile) {
1585
1729
  if (verbose) spinner_default.info(`no video found in: ${entry}`);
1586
1730
  skipped++;
1587
1731
  continue;
1588
1732
  }
1589
- const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
1733
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1590
1734
  const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1591
1735
  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;
1736
+ const destVideoName = `${episodeName}.${videoExt}`;
1737
+ const destVideoPath = (0, import_path14.resolve)(seasonPath, destVideoName);
1738
+ const videoSourcePath = isDir ? (0, import_path14.resolve)(entryPath, videoFile) : entryPath;
1595
1739
  if ((0, import_fs13.existsSync)(destVideoPath)) {
1596
- spinner_default.warn(`already exists: ${episodeName}`);
1597
- skipped++;
1598
- continue;
1740
+ let shouldReplace = force;
1741
+ if (!shouldReplace && interactive) {
1742
+ spinner_default.stop();
1743
+ const select = new import_termkit14.Select();
1744
+ const picked = await select.ask(`Already exists \u2014 replace?`, [
1745
+ { label: `${showFolderName} / ${seasonFolderName} / ${episodeName}`, value: "replace" },
1746
+ { label: "Skip", value: "skip" }
1747
+ ]);
1748
+ spinner_default.start();
1749
+ shouldReplace = picked?.value === "replace";
1750
+ }
1751
+ if (!shouldReplace) {
1752
+ spinner_default.warn(`already exists: ${episodeName}`);
1753
+ skipped++;
1754
+ continue;
1755
+ }
1756
+ if (!dryRun) {
1757
+ for (const f of (0, import_fs13.readdirSync)(seasonPath)) {
1758
+ if (f.startsWith(`${episodeName}.`)) (0, import_fs13.rmSync)((0, import_path14.resolve)(seasonPath, f));
1759
+ }
1760
+ }
1599
1761
  }
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;
1762
+ const dirFiles = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
1763
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1764
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1765
+ const subtitleSourcePath = subtitle ? (0, import_path14.resolve)(entryPath, subtitle) : null;
1766
+ const destSubtitleName = subtitle && subtitleExt ? `${episodeName}.${subtitleExt}` : null;
1605
1767
  if (!dryRun) {
1606
1768
  (0, import_fs13.mkdirSync)(seasonPath, { recursive: true });
1607
1769
  let mode = "move";
1608
1770
  if (useHardlink) {
1609
1771
  try {
1610
- if (!sameDev(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1611
- (0, import_fs13.linkSync)(videoSourcePath2, destVideoPath);
1772
+ if (!sameDev(videoSourcePath, seasonPath)) throw new Error("cross-filesystem");
1773
+ (0, import_fs13.linkSync)(videoSourcePath, destVideoPath);
1612
1774
  mode = "hardlink";
1613
1775
  } catch {
1614
1776
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1615
- (0, import_fs13.cpSync)(videoSourcePath2, destVideoPath);
1777
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1616
1778
  mode = "copy";
1617
1779
  }
1618
- if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs13.cpSync)(subtitleSourcePath2, (0, import_path14.resolve)(seasonPath, destSubtitleName2));
1780
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs13.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(seasonPath, destSubtitleName));
1619
1781
  } else {
1620
- if (sameDev(videoSourcePath2, seasonPath)) {
1621
- (0, import_fs13.renameSync)(videoSourcePath2, destVideoPath);
1782
+ if (sameDev(videoSourcePath, seasonPath)) {
1783
+ (0, import_fs13.renameSync)(videoSourcePath, destVideoPath);
1622
1784
  } else {
1623
- (0, import_fs13.cpSync)(videoSourcePath2, destVideoPath);
1624
- (0, import_fs13.rmSync)(videoSourcePath2);
1785
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1786
+ (0, import_fs13.rmSync)(videoSourcePath);
1625
1787
  }
1626
- if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs13.renameSync)(subtitleSourcePath2, (0, import_path14.resolve)(seasonPath, destSubtitleName2));
1788
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs13.renameSync)(subtitleSourcePath, (0, import_path14.resolve)(seasonPath, destSubtitleName));
1627
1789
  if (isDir) (0, import_fs13.rmSync)(entryPath, { recursive: true, force: true });
1628
1790
  }
1629
1791
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
@@ -1632,66 +1794,40 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1632
1794
  imported++;
1633
1795
  continue;
1634
1796
  }
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}`);
1797
+ if (await importMovie(entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot)) {
1798
+ imported++;
1799
+ } else {
1640
1800
  skipped++;
1641
- continue;
1642
1801
  }
1643
- const videoFile = isDir ? findVideo(entryPath) : entry;
1644
- if (!videoFile) {
1645
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1802
+ }
1803
+ }
1804
+ if (pendingMovies.length > 0) {
1805
+ spinner_default.warn(`${pendingMovies.length} uncertain movie match${pendingMovies.length > 1 ? "es" : ""} skipped \u2014 use -i to review or -f to import all`);
1806
+ for (const p of pendingMovies) spinner_default.info(` ? ${p.entry.replace(/\/$/, "")}`);
1807
+ let toProcess = [];
1808
+ if (interactive) {
1809
+ spinner_default.stop();
1810
+ const ms = new import_termkit14.MultiSelect({ allowSkip: true, search: true, maxHeight: 20 });
1811
+ const items = pendingMovies.map((p) => ({
1812
+ label: p.entry.replace(/\/$/, ""),
1813
+ description: p.parsed.year ? `parsed: ${p.parsed.title} \xB7 ${p.parsed.year}` : `parsed: ${p.parsed.title}`,
1814
+ ...p
1815
+ }));
1816
+ toProcess = await ms.ask("Select entries to import as movies:", items) ?? [];
1817
+ spinner_default.start();
1818
+ skipped += pendingMovies.length - toProcess.length;
1819
+ } else if (force) {
1820
+ toProcess = pendingMovies;
1821
+ } else {
1822
+ skipped += pendingMovies.length;
1823
+ }
1824
+ for (const p of toProcess) {
1825
+ const { tmdbId, resolvedTitle, resolvedYear } = await lookupMovie(p.parsed);
1826
+ if (await importMovie(p.entry, p.entryPath, p.isDir, resolvedTitle, resolvedYear, tmdbId, p.destRoot)) {
1827
+ imported++;
1828
+ } else {
1646
1829
  skipped++;
1647
- continue;
1648
1830
  }
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
- }
1693
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1694
- imported++;
1695
1831
  }
1696
1832
  }
1697
1833
  spinner_default.succeed(`imported ${imported} items`);
@@ -1714,7 +1850,7 @@ var shows = async () => {
1714
1850
  return;
1715
1851
  }
1716
1852
  console.log(`
1717
- ${import_termkit15.Color.yellow.encoder("SHOWS")}${destRoot ? ` ${import_termkit15.Color.blue.encoder(destRoot)}` : ""} (${allShows.length} registered)`);
1853
+ ${import_termkit15.Color.yellow.encoder("SHOWS")}${destRoot ? ` ${import_termkit15.Color.white.encoder(destRoot)}` : ""} (${allShows.length} registered)`);
1718
1854
  new import_termkit15.Table(
1719
1855
  allShows.map((show) => ({
1720
1856
  name: show.path.split("/").pop() ?? show.path,
@@ -1828,7 +1964,7 @@ var undo = async () => {
1828
1964
  let undone = 0;
1829
1965
  for (const record of records) {
1830
1966
  (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)}`);
1967
+ spinner_default.succeed(`${import_termkit17.Color.green.encoder(record.newPath)} \u2192 ${import_termkit17.Color.white.encoder(record.oldPath)}`);
1832
1968
  undone++;
1833
1969
  }
1834
1970
  deleteSession(records[0].sessionId);
@@ -1863,6 +1999,10 @@ var findVideo2 = (dir) => (0, import_fs17.readdirSync)(dir).find((f) => {
1863
1999
  const ext = f.match(/([^.]+$)/)?.[0];
1864
2000
  return ext && videoExtensions_default.includes(ext);
1865
2001
  }) ?? null;
2002
+ var containsBook2 = (dir) => (0, import_fs17.readdirSync)(dir).some((f) => {
2003
+ const ext = f.match(/([^.]+$)/)?.[0];
2004
+ return ext && bookExtensions_default.includes(ext);
2005
+ });
1866
2006
  var findSeasonFolder2 = (showPath, season) => {
1867
2007
  if (!(0, import_fs17.existsSync)(showPath)) return null;
1868
2008
  const folders = (0, import_fs17.readdirSync)(showPath).filter((f) => {
@@ -1886,9 +2026,13 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1886
2026
  const isDir = (0, import_fs17.lstatSync)(entryPath).isDirectory();
1887
2027
  const ext = entry.match(/([^.]+$)/)?.[0];
1888
2028
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1889
- if (!isDir && !isVideo) return;
2029
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
2030
+ const isBookDir = isDir && containsBook2(entryPath);
2031
+ if (!isDir && !isVideo && !isBook) return;
1890
2032
  let detectedType;
1891
- if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
2033
+ if (isBook || isBookDir) {
2034
+ detectedType = "book";
2035
+ } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1892
2036
  detectedType = "ps3";
1893
2037
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1894
2038
  detectedType = "tv";
@@ -1912,7 +2056,28 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1912
2056
  }
1913
2057
  moveItem(entryPath, destPath);
1914
2058
  recordImport(sessionId, entryPath, destPath, "move");
1915
- spinner_default.succeed(`imported ${import_termkit18.Color.cyan.encoder(destName)}`);
2059
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(destName)}`);
2060
+ return;
2061
+ }
2062
+ if (detectedType === "book") {
2063
+ const destPath = (0, import_path16.resolve)(destRoot, entry);
2064
+ if ((0, import_fs17.existsSync)(destPath)) {
2065
+ spinner_default.warn(`already exists: ${entry}`);
2066
+ return;
2067
+ }
2068
+ if (isDir || isBookDir) {
2069
+ moveItem(entryPath, destPath);
2070
+ } else {
2071
+ (0, import_fs17.mkdirSync)(destRoot, { recursive: true });
2072
+ if (sameDev2(entryPath, destRoot)) {
2073
+ (0, import_fs17.renameSync)(entryPath, destPath);
2074
+ } else {
2075
+ (0, import_fs17.cpSync)(entryPath, destPath);
2076
+ (0, import_fs17.rmSync)(entryPath);
2077
+ }
2078
+ }
2079
+ recordImport(sessionId, entryPath, destPath, "move");
2080
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(entry)}`);
1916
2081
  return;
1917
2082
  }
1918
2083
  const parsed = parseDownloadName(entry);
@@ -1985,7 +2150,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1985
2150
  if (isDir) (0, import_fs17.rmSync)(entryPath, { recursive: true, force: true });
1986
2151
  }
1987
2152
  recordImport(sessionId, entryPath, seasonPath, mode);
1988
- spinner_default.succeed(`imported ${import_termkit18.Color.cyan.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
2153
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1989
2154
  return;
1990
2155
  }
1991
2156
  const edition = detectEdition(entry);
@@ -2042,7 +2207,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2042
2207
  }
2043
2208
  recordImport(sessionId, entryPath, destFolder, "move");
2044
2209
  }
2045
- spinner_default.succeed(`imported ${import_termkit18.Color.cyan.encoder(folderName)}`);
2210
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(folderName)}`);
2046
2211
  };
2047
2212
  var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2048
2213
  const config = getConfig();
@@ -2073,31 +2238,35 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2073
2238
  watcher.on("add", handle);
2074
2239
  spinner_default.start();
2075
2240
  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)}`);
2241
+ for (const s of config.sources) spinner_default.info(` ${import_termkit18.Color.white.encoder(s)}`);
2077
2242
  spinner_default.stop();
2078
2243
  process.stdin.resume();
2079
2244
  };
2080
2245
  var watch_default = watch;
2081
2246
 
2082
2247
  // package.json
2083
- var version = "0.2.0";
2248
+ var version = "0.2.1";
2084
2249
 
2085
2250
  // src/program.ts
2086
- var adapt = (fn) => (options) => fn(options);
2251
+ var toCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2252
+ var adapt = (fn) => (options) => {
2253
+ const camel = Object.fromEntries(Object.entries(options).map(([k, v]) => [toCamel(k), v]));
2254
+ return fn(camel);
2255
+ };
2087
2256
  var { command, option } = import_termkit19.Program;
2088
2257
  var program = import_termkit19.Program.command("reelsort").version(version).description("a cli to manage media").commands([
2089
2258
  command("config").description("manage configuration").commands([
2090
2259
  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))]),
2260
+ 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
2261
  command("set", "<key> <subkey> [value]").description("set a value (e.g. set language eng)").action(adapt(configSet)),
2093
2262
  command("show").description("show current configuration").action(adapt(configShow))
2094
2263
  ]),
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)),
2264
+ 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
2265
  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)),
2266
+ 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)),
2267
+ 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
2268
  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)),
2269
+ 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
2270
  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
2271
  command("undo").description("undo the last rename session").action(adapt(undo_default)),
2103
2272
  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)),