reelsort 0.1.2 → 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
@@ -24,13 +24,12 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/program.ts
27
- var import_termkit = require("termkit");
27
+ var import_termkit19 = require("termkit");
28
28
 
29
29
  // src/actions/add.ts
30
- var import_cosmetic = __toESM(require("cosmetic"));
31
30
  var import_fs3 = require("fs");
32
31
  var import_path3 = require("path");
33
- var import_termpulse2 = require("termpulse");
32
+ var import_termkit2 = require("termkit");
34
33
 
35
34
  // src/config.ts
36
35
  var import_fs = require("fs");
@@ -214,18 +213,17 @@ var searchMovie = async (title, year, apiKey) => {
214
213
  if (year) url.searchParams.set("year", String(year));
215
214
  try {
216
215
  const res = await fetch(url.toString());
217
- if (!res.ok) return null;
216
+ if (!res.ok) return [];
218
217
  const data = await res.json();
219
- const first = data.results[0];
220
- if (!first) return null;
221
- return {
222
- id: first.id,
223
- title: first.title,
224
- year: first.release_date ? parseInt(first.release_date.slice(0, 4)) : void 0,
225
- url: `${TMDB_WEB}/movie/${first.id}`
226
- };
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
+ }));
227
225
  } catch {
228
- return null;
226
+ return [];
229
227
  }
230
228
  };
231
229
  var getEpisodeName = async (seriesId, season, episode, apiKey) => {
@@ -277,28 +275,21 @@ var searchTv = async (title, apiKey) => {
277
275
  };
278
276
 
279
277
  // src/refs/spinner.ts
280
- var import_termpulse = require("termpulse");
278
+ var import_termkit = require("termkit");
281
279
  var Spinner = class {
282
280
  spinner;
283
- _isSpinning = false;
284
- _text = "";
285
281
  constructor() {
286
- this.spinner = new import_termpulse.Spinner();
282
+ this.spinner = new import_termkit.Spinner();
287
283
  }
288
284
  get text() {
289
- return this._text;
285
+ return this.spinner.text;
290
286
  }
291
287
  set text(t) {
292
- this._text = t;
293
- if (this._isSpinning) this.spinner.message(t);
294
- }
295
- get isSpinning() {
296
- return this._isSpinning;
288
+ this.spinner.text = t;
297
289
  }
298
290
  start(s) {
299
- if (s) this._text = s;
291
+ if (s) this.spinner.text = s;
300
292
  this.spinner.start();
301
- this._isSpinning = true;
302
293
  return this;
303
294
  }
304
295
  info(s) {
@@ -319,8 +310,6 @@ var Spinner = class {
319
310
  }
320
311
  stop() {
321
312
  this.spinner.stop();
322
- this._isSpinning = false;
323
- process.stdin.resume();
324
313
  return this;
325
314
  }
326
315
  };
@@ -338,7 +327,7 @@ var add = async ({ name }) => {
338
327
  if (results.length === 0) throw new Error(`no TMDb results for "${name}"`);
339
328
  let picked = results.length === 1 ? results[0] : null;
340
329
  if (!picked) {
341
- const select = new import_termpulse2.Select();
330
+ const select = new import_termkit2.Select();
342
331
  const items = results.map((r) => ({
343
332
  label: r.year ? `${r.title} (${r.year})` : r.title,
344
333
  description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
@@ -353,14 +342,14 @@ var add = async ({ name }) => {
353
342
  (0, import_fs3.mkdirSync)(showPath, { recursive: true });
354
343
  upsertShow(showPath, picked.id, picked.title);
355
344
  spinner_default.start();
356
- spinner_default.succeed(`added ${import_cosmetic.default.cyan.encoder(folderName)}`);
345
+ spinner_default.succeed(`added ${import_termkit2.Color.green.encoder(folderName)}`);
357
346
  spinner_default.stop();
358
347
  };
359
348
  var add_default = add;
360
349
 
361
350
  // src/actions/clean.ts
362
- var import_cosmetic2 = __toESM(require("cosmetic"));
363
351
  var import_fs4 = require("fs");
352
+ var import_termkit3 = require("termkit");
364
353
  var parseOlderThan = (s) => {
365
354
  const match = s.match(/^(\d+)([dhm])$/);
366
355
  if (!match) return null;
@@ -393,17 +382,17 @@ var clean = async ({ dryRun, olderThan }) => {
393
382
  continue;
394
383
  }
395
384
  if (dryRun) {
396
- spinner_default.succeed(`[dry] would remove ${import_cosmetic2.default.blue.encoder(imp.sourcePath)}`);
385
+ spinner_default.succeed(`[dry] would remove ${import_termkit3.Color.white.encoder(imp.sourcePath)}`);
397
386
  cleaned++;
398
387
  continue;
399
388
  }
400
389
  try {
401
390
  (0, import_fs4.rmSync)(imp.sourcePath, { recursive: true, force: true });
402
391
  deleteImport(imp.id);
403
- spinner_default.succeed(`removed ${import_cosmetic2.default.blue.encoder(imp.sourcePath)}`);
392
+ spinner_default.succeed(`removed ${import_termkit3.Color.white.encoder(imp.sourcePath)}`);
404
393
  cleaned++;
405
394
  } catch {
406
- spinner_default.warn(`locked or inaccessible, skipped: ${import_cosmetic2.default.blue.encoder(imp.sourcePath)}`);
395
+ spinner_default.warn(`locked or inaccessible, skipped: ${import_termkit3.Color.white.encoder(imp.sourcePath)}`);
407
396
  skipped++;
408
397
  }
409
398
  }
@@ -414,8 +403,8 @@ var clean = async ({ dryRun, olderThan }) => {
414
403
  var clean_default = clean;
415
404
 
416
405
  // src/actions/config.ts
417
- var import_cosmetic3 = __toESM(require("cosmetic"));
418
406
  var import_path4 = require("path");
407
+ var import_termkit4 = require("termkit");
419
408
 
420
409
  // src/helpers/formatEpisode.ts
421
410
  var DEFAULT_EPISODE_FORMAT = "{s}x{ee}";
@@ -434,38 +423,65 @@ var formatEpisode = (season, episode, format = DEFAULT_EPISODE_FORMAT, double =
434
423
  };
435
424
 
436
425
  // src/actions/config.ts
437
- var DEST_TYPES = ["movie", "tv", "ps3"];
438
- var configAdd = async ({ key, value }) => {
439
- if (key !== "source") throw new Error(`unknown key '${key}', expected: source`);
440
- const dir = (0, import_path4.resolve)(value);
426
+ var DEST_TYPES = ["movie", "tv", "ps3", "book"];
427
+ var sourceAdd = async ({ dir }) => {
428
+ const resolved = (0, import_path4.resolve)(dir);
441
429
  const config = getConfig();
442
- if (config.sources.includes(dir)) {
430
+ if (config.sources.includes(resolved)) {
443
431
  spinner_default.start();
444
- spinner_default.info(`source already configured: ${import_cosmetic3.default.blue.encoder(dir)}`);
432
+ spinner_default.info(`source already configured: ${import_termkit4.Color.white.encoder(resolved)}`);
445
433
  spinner_default.stop();
446
434
  return;
447
435
  }
448
- config.sources.push(dir);
436
+ config.sources.push(resolved);
449
437
  saveConfig(config);
450
438
  spinner_default.start();
451
- spinner_default.succeed(`added source: ${import_cosmetic3.default.blue.encoder(dir)}`);
439
+ spinner_default.succeed(`added source: ${import_termkit4.Color.white.encoder(resolved)}`);
452
440
  spinner_default.stop();
453
441
  };
454
- var configRemove = async ({ key, value }) => {
455
- if (key !== "source") throw new Error(`unknown key '${key}', expected: source`);
456
- const dir = (0, import_path4.resolve)(value);
442
+ var sourceRemove = async ({ dir }) => {
443
+ const resolved = (0, import_path4.resolve)(dir);
457
444
  const config = getConfig();
458
- const index = config.sources.indexOf(dir);
445
+ const index = config.sources.indexOf(resolved);
459
446
  if (index === -1) {
460
447
  spinner_default.start();
461
- spinner_default.warn(`source not found: ${import_cosmetic3.default.blue.encoder(dir)}`);
448
+ spinner_default.warn(`source not found: ${import_termkit4.Color.white.encoder(resolved)}`);
462
449
  spinner_default.stop();
463
450
  return;
464
451
  }
465
452
  config.sources.splice(index, 1);
466
453
  saveConfig(config);
467
454
  spinner_default.start();
468
- spinner_default.succeed(`removed source: ${import_cosmetic3.default.blue.encoder(dir)}`);
455
+ spinner_default.succeed(`removed source: ${import_termkit4.Color.white.encoder(resolved)}`);
456
+ spinner_default.stop();
457
+ };
458
+ var destAdd = async ({ type, dir }) => {
459
+ if (!DEST_TYPES.includes(type)) {
460
+ throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
461
+ }
462
+ const resolved = (0, import_path4.resolve)(dir);
463
+ const config = getConfig();
464
+ config.dest[type] = resolved;
465
+ saveConfig(config);
466
+ spinner_default.start();
467
+ spinner_default.succeed(`set ${type} destination: ${import_termkit4.Color.green.encoder(resolved)}`);
468
+ spinner_default.stop();
469
+ };
470
+ var destRemove = async ({ type }) => {
471
+ if (!DEST_TYPES.includes(type)) {
472
+ throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
473
+ }
474
+ const config = getConfig();
475
+ if (!config.dest[type]) {
476
+ spinner_default.start();
477
+ spinner_default.warn(`no ${type} destination configured`);
478
+ spinner_default.stop();
479
+ return;
480
+ }
481
+ delete config.dest[type];
482
+ saveConfig(config);
483
+ spinner_default.start();
484
+ spinner_default.succeed(`removed ${type} destination`);
469
485
  spinner_default.stop();
470
486
  };
471
487
  var configSet = async ({ key, subkey, value }) => {
@@ -474,7 +490,7 @@ var configSet = async ({ key, subkey, value }) => {
474
490
  config.language = subkey;
475
491
  saveConfig(config);
476
492
  spinner_default.start();
477
- spinner_default.succeed(`set subtitle language: ${import_cosmetic3.default.cyan.encoder(subkey)}`);
493
+ spinner_default.succeed(`set subtitle language: ${import_termkit4.Color.green.encoder(subkey)}`);
478
494
  spinner_default.stop();
479
495
  return;
480
496
  }
@@ -504,21 +520,11 @@ var configSet = async ({ key, subkey, value }) => {
504
520
  }
505
521
  saveConfig(config);
506
522
  spinner_default.start();
507
- spinner_default.succeed(`set ${subkey} format: ${import_cosmetic3.default.cyan.encoder(value ?? subkey)}`);
523
+ spinner_default.succeed(`set ${subkey} format: ${import_termkit4.Color.green.encoder(value ?? subkey)}`);
508
524
  spinner_default.stop();
509
525
  return;
510
526
  }
511
- if (key !== "dest") throw new Error(`unknown key '${key}', expected: dest, language, tmdb-key, format`);
512
- if (!DEST_TYPES.includes(subkey)) {
513
- throw new Error(`unknown type '${subkey}', expected: ${DEST_TYPES.join(", ")}`);
514
- }
515
- if (!value) throw new Error(`missing path for dest ${subkey}`);
516
- const dir = (0, import_path4.resolve)(value);
517
- config.dest[subkey] = dir;
518
- saveConfig(config);
519
- spinner_default.start();
520
- spinner_default.succeed(`set ${subkey} destination: ${import_cosmetic3.default.cyan.encoder(dir)}`);
521
- spinner_default.stop();
527
+ throw new Error(`unknown key '${key}', expected: language, tmdb-key, format`);
522
528
  };
523
529
  var configShow = async () => {
524
530
  const config = getConfig();
@@ -526,7 +532,7 @@ var configShow = async () => {
526
532
  if (config.sources.length === 0) {
527
533
  console.log(" (none)");
528
534
  } else {
529
- for (const s of config.sources) console.log(` ${import_cosmetic3.default.blue.encoder(s)}`);
535
+ for (const s of config.sources) console.log(` ${import_termkit4.Color.white.encoder(s)}`);
530
536
  }
531
537
  console.log("\nDestinations:");
532
538
  const entries = DEST_TYPES.map((t) => ({ type: t, path: config.dest[t] })).filter((e) => e.path);
@@ -534,26 +540,26 @@ var configShow = async () => {
534
540
  console.log(" (none)");
535
541
  } else {
536
542
  for (const { type, path } of entries) {
537
- console.log(` ${type.padEnd(6)} ${import_cosmetic3.default.cyan.encoder(path)}`);
543
+ console.log(` ${type.padEnd(6)} ${import_termkit4.Color.green.encoder(path)}`);
538
544
  }
539
545
  }
540
546
  console.log(`
541
- Subtitle language: ${import_cosmetic3.default.cyan.encoder(config.language ?? "eng (default)")}`);
542
- console.log(`TMDb API key: ${config.tmdbApiKey ? import_cosmetic3.default.green.encoder("configured") : import_cosmetic3.default.red.encoder("not set")}`);
543
- console.log(`Movie format: ${import_cosmetic3.default.cyan.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
544
- console.log(`Episode format: ${import_cosmetic3.default.cyan.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
545
- console.log(`Season folder: ${import_cosmetic3.default.cyan.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
547
+ Subtitle language: ${import_termkit4.Color.green.encoder(config.language ?? "eng (default)")}`);
548
+ console.log(`TMDb API key: ${config.tmdbApiKey ? import_termkit4.Color.green.encoder("configured") : import_termkit4.Color.red.encoder("not set")}`);
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)")}`);
546
552
  console.log();
547
553
  };
548
554
 
549
555
  // src/actions/differences.ts
550
- var import_cosmetic4 = __toESM(require("cosmetic"));
551
556
  var import_fs5 = require("fs");
552
557
  var import_path5 = require("path");
558
+ var import_termkit5 = require("termkit");
553
559
  var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
554
560
  let dir1 = rawDir1;
555
561
  let dir2 = rawDir2;
556
- spinner_default.text = `checking differences between ${import_cosmetic4.default.blue.encoder(dir1)} and ${import_cosmetic4.default.blue.encoder(dir2)}`;
562
+ spinner_default.text = `checking differences between ${import_termkit5.Color.white.encoder(dir1)} and ${import_termkit5.Color.white.encoder(dir2)}`;
557
563
  spinner_default.start();
558
564
  dir1 = (0, import_path5.resolve)(dir1);
559
565
  dir2 = (0, import_path5.resolve)(dir2);
@@ -589,18 +595,17 @@ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
589
595
  removed.push(l);
590
596
  }
591
597
  }
592
- spinner_default.succeed(`checked differences between ${import_cosmetic4.default.blue.encoder(dir1)} and ${import_cosmetic4.default.blue.encoder(dir2)}`);
598
+ spinner_default.succeed(`checked differences between ${import_termkit5.Color.white.encoder(dir1)} and ${import_termkit5.Color.white.encoder(dir2)}`);
593
599
  spinner_default.succeed(`found ${added.length} added files`);
594
600
  spinner_default.succeed(`found ${removed.length} removed files`);
595
601
  spinner_default.stop();
596
- for (const i of added) console.log(`${import_cosmetic4.default.green.encoder("added")} ${i}`);
597
- for (const i of removed) console.log(`${import_cosmetic4.default.red.encoder("removed")} ${i}`);
602
+ for (const i of added) console.log(`${import_termkit5.Color.green.encoder("added")} ${i}`);
603
+ for (const i of removed) console.log(`${import_termkit5.Color.red.encoder("removed")} ${i}`);
598
604
  };
599
605
  var differences_default = differences;
600
606
 
601
607
  // src/actions/ended.ts
602
- var import_cosmetic5 = __toESM(require("cosmetic"));
603
- var import_termpulse3 = require("termpulse");
608
+ var import_termkit6 = require("termkit");
604
609
  var ended = async ({ remove }) => {
605
610
  const shows2 = getShows();
606
611
  const candidates = shows2.filter((s) => remove ? s.ended : !s.ended);
@@ -610,7 +615,7 @@ var ended = async ({ remove }) => {
610
615
  spinner_default.stop();
611
616
  return;
612
617
  }
613
- const select = new import_termpulse3.Select();
618
+ const select = new import_termkit6.Select();
614
619
  const items = candidates.map((s) => ({
615
620
  label: s.path.split("/").pop() ?? s.path,
616
621
  path: s.path
@@ -621,17 +626,17 @@ var ended = async ({ remove }) => {
621
626
  setShowEnded(picked.path, !remove);
622
627
  spinner_default.start();
623
628
  if (remove) {
624
- spinner_default.succeed(`marked as active: ${import_cosmetic5.default.cyan.encoder(picked.label)}`);
629
+ spinner_default.succeed(`marked as active: ${import_termkit6.Color.green.encoder(picked.label)}`);
625
630
  } else {
626
- spinner_default.succeed(`marked as ended: ${import_cosmetic5.default.cyan.encoder(picked.label)}`);
631
+ spinner_default.succeed(`marked as ended: ${import_termkit6.Color.green.encoder(picked.label)}`);
627
632
  }
628
633
  spinner_default.stop();
629
634
  };
630
635
  var ended_default = ended;
631
636
 
632
637
  // src/actions/history.ts
633
- var import_cosmetic6 = __toESM(require("cosmetic"));
634
638
  var import_path6 = require("path");
639
+ var import_termkit7 = require("termkit");
635
640
  var history = async ({ limit, imports }) => {
636
641
  if (imports) {
637
642
  const sessions = getImportHistory(limit ?? 10);
@@ -643,11 +648,11 @@ var history = async ({ limit, imports }) => {
643
648
  const date = new Date(session.sessionId);
644
649
  const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
645
650
  console.log(`
646
- ${import_cosmetic6.default.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
651
+ ${import_termkit7.Color.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
647
652
  for (const r of session.records) {
648
653
  const src = (0, import_path6.basename)(r.sourcePath);
649
- const dest = import_cosmetic6.default.cyan.encoder(r.destinationPath);
650
- const mode = r.mode !== "move" ? ` ${import_cosmetic6.default.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}]`)}` : "";
651
656
  console.log(` ${src} \u2192 ${dest}${mode}`);
652
657
  }
653
658
  }
@@ -662,11 +667,11 @@ ${import_cosmetic6.default.yellow.encoder(label)} (${session.records.length} it
662
667
  const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
663
668
  const folders = session.records.filter((r) => (0, import_path6.extname)(r.newPath) === "");
664
669
  console.log(`
665
- ${import_cosmetic6.default.yellow.encoder(label)} (${folders.length} item${folders.length !== 1 ? "s" : ""})`);
670
+ ${import_termkit7.Color.yellow.encoder(label)} (${folders.length} item${folders.length !== 1 ? "s" : ""})`);
666
671
  for (const r of folders) {
667
672
  const oldName = (0, import_path6.basename)(r.oldPath);
668
673
  const newName = (0, import_path6.basename)(r.newPath);
669
- console.log(` ${import_cosmetic6.default.blue.encoder(oldName)} \u2192 ${import_cosmetic6.default.cyan.encoder(newName)}`);
674
+ console.log(` ${import_termkit7.Color.white.encoder(oldName)} \u2192 ${import_termkit7.Color.green.encoder(newName)}`);
670
675
  }
671
676
  }
672
677
  }
@@ -675,10 +680,9 @@ ${import_cosmetic6.default.yellow.encoder(label)} (${folders.length} item${fold
675
680
  var history_default = history;
676
681
 
677
682
  // src/actions/link.ts
678
- var import_cosmetic7 = __toESM(require("cosmetic"));
679
683
  var import_fs6 = require("fs");
680
684
  var import_path7 = require("path");
681
- var import_termpulse4 = require("termpulse");
685
+ var import_termkit8 = require("termkit");
682
686
  var parseShowTitle = (folderName) => {
683
687
  const withoutYear = folderName.replace(/\s*\(\d{4}\)\s*$/, "").trim();
684
688
  return withoutYear || folderName;
@@ -705,7 +709,7 @@ var link = async ({ force }) => {
705
709
  skipped++;
706
710
  continue;
707
711
  }
708
- spinner_default.start(`linking ${import_cosmetic7.default.blue.encoder(show)}`);
712
+ spinner_default.start(`linking ${import_termkit8.Color.white.encoder(show)}`);
709
713
  const title = parseShowTitle(show);
710
714
  const results = await searchTv(title, config.tmdbApiKey);
711
715
  if (results.length === 0) {
@@ -715,12 +719,12 @@ var link = async ({ force }) => {
715
719
  }
716
720
  if (results.length === 1) {
717
721
  upsertShow(showPath, results[0].id, results[0].title);
718
- spinner_default.succeed(`${show} \u2192 ${import_cosmetic7.default.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})`);
719
723
  linked++;
720
724
  continue;
721
725
  }
722
726
  spinner_default.stop();
723
- const select = new import_termpulse4.Select();
727
+ const select = new import_termkit8.Select();
724
728
  const items = results.map((r) => ({
725
729
  label: r.year ? `${r.title} (${r.year})` : r.title,
726
730
  description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
@@ -730,7 +734,7 @@ var link = async ({ force }) => {
730
734
  spinner_default.start();
731
735
  if (picked) {
732
736
  upsertShow(showPath, picked.id, picked.title);
733
- spinner_default.succeed(`${show} \u2192 ${import_cosmetic7.default.cyan.encoder(picked.title)} (${picked.year})`);
737
+ spinner_default.succeed(`${show} \u2192 ${import_termkit8.Color.green.encoder(picked.title)} (${picked.year})`);
734
738
  linked++;
735
739
  } else {
736
740
  spinner_default.info(`skipped: ${show}`);
@@ -745,9 +749,9 @@ var link = async ({ force }) => {
745
749
  var link_default = link;
746
750
 
747
751
  // src/actions/list.ts
748
- var import_cosmetic8 = __toESM(require("cosmetic"));
749
752
  var import_fs8 = require("fs");
750
753
  var import_path9 = require("path");
754
+ var import_termkit9 = require("termkit");
751
755
 
752
756
  // src/helpers/dirSize.ts
753
757
  var import_fs7 = require("fs");
@@ -879,7 +883,6 @@ var parseLibraryFolder = (name) => {
879
883
  if (match) return { title: match[1].trim(), year: parseInt(match[2]) };
880
884
  return { title: name };
881
885
  };
882
- var col = (s, width) => s.padEnd(width).substring(0, width);
883
886
  var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter, sort }) => {
884
887
  const config = getConfig();
885
888
  const types = (type ? [type] : DEST_TYPES2).filter((t) => config.dest[t]);
@@ -888,7 +891,7 @@ var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter
888
891
  const destRoot = config.dest[t];
889
892
  if (!(0, import_fs8.existsSync)(destRoot)) {
890
893
  console.log(`
891
- ${t.toUpperCase()} ${import_cosmetic8.default.blue.encoder(destRoot)} (not found)`);
894
+ ${t.toUpperCase()} ${import_termkit9.Color.white.encoder(destRoot)} (not found)`);
892
895
  continue;
893
896
  }
894
897
  const folders = (0, import_fs8.readdirSync)(destRoot).filter((f) => {
@@ -918,18 +921,29 @@ ${t.toUpperCase()} ${import_cosmetic8.default.blue.encoder(destRoot)} (not fou
918
921
  const yearDiff = (b.year ?? 0) - (a.year ?? 0);
919
922
  return yearDiff !== 0 ? yearDiff : a.title.localeCompare(b.title);
920
923
  });
921
- const titleW = Math.min(50, Math.max(10, ...filtered.map((e) => e.title.length)) + 2);
922
- const divider = "\u2500".repeat(titleW + 44);
923
924
  console.log(`
924
- ${import_cosmetic8.default.yellow.encoder(t.toUpperCase())} ${import_cosmetic8.default.blue.encoder(destRoot)}`);
925
- console.log(divider);
926
- console.log(`${"Title".padEnd(titleW)} ${"Year".padEnd(6)} ${"Res".padEnd(6)} ${"Codec".padEnd(6)} ${"Size".padEnd(10)} Sub`);
927
- console.log(divider);
928
- for (const e of filtered) {
929
- const sub = e.hasSub ? import_cosmetic8.default.green.encoder("\u2713") : import_cosmetic8.default.red.encoder("\u2717");
930
- console.log(`${col(e.title, titleW)} ${col(e.year?.toString() ?? "\u2014", 6)} ${col(e.resolution ?? "\u2014", 6)} ${col(e.codec ?? "\u2014", 6)} ${col(e.size, 10)} ${sub}`);
931
- }
932
- console.log(divider);
925
+ ${import_termkit9.Color.yellow.encoder(t.toUpperCase())} ${import_termkit9.Color.white.encoder(destRoot)}`);
926
+ new import_termkit9.Table(
927
+ filtered.map((e) => ({
928
+ title: e.title,
929
+ year: e.year,
930
+ resolution: e.resolution,
931
+ codec: e.codec,
932
+ size: e.size,
933
+ sub: e.hasSub
934
+ })),
935
+ {
936
+ separator: " ",
937
+ columns: [
938
+ { key: "title", title: "Title" },
939
+ { key: "year", title: "Year", align: "right", value: (v) => v != null ? String(v) : "\u2014" },
940
+ { key: "resolution", title: "Res", value: (v) => v ?? "\u2014" },
941
+ { key: "codec", title: "Codec", value: (v) => v ?? "\u2014" },
942
+ { key: "size", title: "Size" },
943
+ { key: "sub", title: "Sub", value: (v) => v ? import_termkit9.Color.green.encoder("\u2713") : import_termkit9.Color.red.encoder("\u2717") }
944
+ ]
945
+ }
946
+ ).print();
933
947
  console.log(`${filtered.length} of ${entries.length} item${entries.length !== 1 ? "s" : ""}`);
934
948
  }
935
949
  console.log();
@@ -937,9 +951,9 @@ ${import_cosmetic8.default.yellow.encoder(t.toUpperCase())} ${import_cosmetic8.
937
951
  var list_default = list;
938
952
 
939
953
  // src/actions/missing.ts
940
- var import_cosmetic9 = __toESM(require("cosmetic"));
941
954
  var import_fs9 = require("fs");
942
955
  var import_path10 = require("path");
956
+ var import_termkit10 = require("termkit");
943
957
  var TODAY = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
944
958
  var parseSeasonNumber = (folderName) => {
945
959
  const match = folderName.match(/(?:season|s)\s*0*(\d+)/i);
@@ -1001,7 +1015,7 @@ var missing = async ({ show: showFilter }) => {
1001
1015
  if (showMissing.length > 0) {
1002
1016
  totalMissing += showMissing.length;
1003
1017
  console.log(`
1004
- ${import_cosmetic9.default.yellow.encoder(show)}`);
1018
+ ${import_termkit10.Color.yellow.encoder(show)}`);
1005
1019
  for (const line of showMissing) console.log(line);
1006
1020
  }
1007
1021
  }
@@ -1020,9 +1034,9 @@ var missing_default = missing;
1020
1034
 
1021
1035
  // src/actions/probe.ts
1022
1036
  var import_child_process = require("child_process");
1023
- var import_cosmetic10 = __toESM(require("cosmetic"));
1024
1037
  var import_fs10 = require("fs");
1025
1038
  var import_path11 = require("path");
1039
+ var import_termkit11 = require("termkit");
1026
1040
  var DEST_TYPES3 = ["movie", "tv", "ps3"];
1027
1041
  var CODEC_MAP2 = {
1028
1042
  hevc: "x265",
@@ -1098,7 +1112,7 @@ var probe = async ({ type, force, verbose }) => {
1098
1112
  for (const t of types) {
1099
1113
  const destRoot = config.dest[t];
1100
1114
  if (!(0, import_fs10.existsSync)(destRoot)) continue;
1101
- spinner_default.text = `scanning ${import_cosmetic10.default.blue.encoder(destRoot)}`;
1115
+ spinner_default.text = `scanning ${import_termkit11.Color.white.encoder(destRoot)}`;
1102
1116
  const files = walkVideoFiles(destRoot);
1103
1117
  for (const filePath of files) {
1104
1118
  if (!force && getMediaInfo(filePath)) {
@@ -1106,7 +1120,7 @@ var probe = async ({ type, force, verbose }) => {
1106
1120
  skipped++;
1107
1121
  continue;
1108
1122
  }
1109
- spinner_default.text = `probing ${import_cosmetic10.default.blue.encoder(filePath)}`;
1123
+ spinner_default.text = `probing ${import_termkit11.Color.white.encoder(filePath)}`;
1110
1124
  const result = runFfprobe(filePath);
1111
1125
  if (!result) {
1112
1126
  if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
@@ -1126,10 +1140,10 @@ var probe = async ({ type, force, verbose }) => {
1126
1140
  var probe_default = probe;
1127
1141
 
1128
1142
  // src/actions/rename.ts
1129
- var import_cosmetic11 = __toESM(require("cosmetic"));
1130
1143
  var import_fs11 = require("fs");
1131
1144
  var import_path12 = require("path");
1132
1145
  var import_rimraf = require("rimraf");
1146
+ var import_termkit12 = require("termkit");
1133
1147
 
1134
1148
  // src/helpers/findSubtitle.ts
1135
1149
  var LANGUAGE_ALIASES = {
@@ -1190,13 +1204,13 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1190
1204
  const config = getConfig();
1191
1205
  const language = config.language ?? "eng";
1192
1206
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1193
- spinner_default.text = `renaming in ${import_cosmetic11.default.blue.encoder(dir)}`;
1207
+ spinner_default.text = `renaming in ${import_termkit12.Color.white.encoder(dir)}`;
1194
1208
  spinner_default.start();
1195
1209
  if (!(0, import_fs11.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
1196
1210
  const list2 = (0, import_fs11.readdirSync)(dir);
1197
1211
  let renamed = 0, removed = 0, skipped = 0;
1198
1212
  for (const [index, entry] of list2.entries()) {
1199
- spinner_default.text = `renaming in ${import_cosmetic11.default.blue.encoder(dir)} ${index + 1}/${list2.length}`;
1213
+ spinner_default.text = `renaming in ${import_termkit12.Color.white.encoder(dir)} ${index + 1}/${list2.length}`;
1200
1214
  if (!(0, import_fs11.lstatSync)((0, import_path12.resolve)(dir, entry)).isDirectory()) {
1201
1215
  if (verbose) spinner_default.info(`skipped ${entry}`);
1202
1216
  skipped++;
@@ -1281,18 +1295,18 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1281
1295
  spinner_default.succeed(`renamed ${renamed} files`);
1282
1296
  if (removed) spinner_default.info(`removed ${removed} files`);
1283
1297
  spinner_default.info(`skipped ${skipped} files`);
1284
- spinner_default.succeed(`done in ${import_cosmetic11.default.cyan.encoder(dir)}`);
1298
+ spinner_default.succeed(`done in ${import_termkit12.Color.green.encoder(dir)}`);
1285
1299
  spinner_default.stop();
1286
1300
  };
1287
1301
  var rename_default = rename;
1288
1302
 
1289
1303
  // src/actions/reset.ts
1290
- var import_cosmetic12 = __toESM(require("cosmetic"));
1291
1304
  var import_fs12 = require("fs");
1292
1305
  var import_path13 = require("path");
1306
+ var import_termkit13 = require("termkit");
1293
1307
  var reset = async ({ dir: inputDir, double }) => {
1294
1308
  let dir = inputDir;
1295
- spinner_default.text = `resetting episodes in ${import_cosmetic12.default.blue.encoder(dir)}`;
1309
+ spinner_default.text = `resetting episodes in ${import_termkit13.Color.white.encoder(dir)}`;
1296
1310
  spinner_default.start();
1297
1311
  dir = (0, import_path13.resolve)(dir);
1298
1312
  if (!(0, import_fs12.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
@@ -1321,7 +1335,7 @@ var reset = async ({ dir: inputDir, double }) => {
1321
1335
  const episodeFormat = getConfig().format?.episode;
1322
1336
  let renamed = 0, skipped = other.length;
1323
1337
  for (const [index, i] of sublist.entries()) {
1324
- spinner_default.text = `resetting episodes in ${import_cosmetic12.default.blue.encoder(dir)} ${index}/${list2.length}`;
1338
+ spinner_default.text = `resetting episodes in ${import_termkit13.Color.white.encoder(dir)} ${index}/${list2.length}`;
1325
1339
  const ext = i.match(/([^.]+$)/)?.[0];
1326
1340
  const episode = double ? index * 2 + 1 : index + 1;
1327
1341
  const name = `${formatEpisode(seasonNum, episode, episodeFormat, double, showTitle)}.${ext}`;
@@ -1334,16 +1348,15 @@ var reset = async ({ dir: inputDir, double }) => {
1334
1348
  }
1335
1349
  spinner_default.succeed(`renamed ${renamed} files`);
1336
1350
  spinner_default.info(`skipped ${skipped} files`);
1337
- spinner_default.succeed(`done in ${import_cosmetic12.default.cyan.encoder(dir)}`);
1351
+ spinner_default.succeed(`done in ${import_termkit13.Color.green.encoder(dir)}`);
1338
1352
  spinner_default.stop();
1339
1353
  };
1340
1354
  var reset_default = reset;
1341
1355
 
1342
1356
  // src/actions/scan.ts
1343
- var import_cosmetic13 = __toESM(require("cosmetic"));
1344
1357
  var import_fs13 = require("fs");
1345
1358
  var import_path14 = require("path");
1346
- var import_termpulse5 = require("termpulse");
1359
+ var import_termkit14 = require("termkit");
1347
1360
 
1348
1361
  // src/helpers/detectEdition.ts
1349
1362
  var EDITIONS = [
@@ -1403,6 +1416,9 @@ var parseDownloadName = (name) => {
1403
1416
  return { title: titleCase_default(titleTokens.join(" ")), year, type: "movie" };
1404
1417
  };
1405
1418
 
1419
+ // src/refs/bookExtensions.json
1420
+ var bookExtensions_default = ["epub", "mobi", "azw3", "azw"];
1421
+
1406
1422
  // src/actions/scan.ts
1407
1423
  var sameDev = (a, b) => {
1408
1424
  try {
@@ -1425,6 +1441,10 @@ var findVideo = (dir) => (0, import_fs13.readdirSync)(dir).find((f) => {
1425
1441
  const ext = f.match(/([^.]+$)/)?.[0];
1426
1442
  return ext && videoExtensions_default.includes(ext);
1427
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
+ });
1428
1448
  var findSeasonFolder = (showPath, season) => {
1429
1449
  if (!(0, import_fs13.existsSync)(showPath)) return null;
1430
1450
  const folders = (0, import_fs13.readdirSync)(showPath).filter((f) => {
@@ -1439,27 +1459,128 @@ var findSeasonFolder = (showPath, season) => {
1439
1459
  return match && parseInt(match[1]) === season;
1440
1460
  }) ?? null;
1441
1461
  };
1442
- 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 }) => {
1443
1472
  const config = getConfig();
1444
1473
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1445
1474
  const language = config.language ?? "eng";
1446
1475
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1447
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
+ };
1448
1566
  spinner_default.start();
1449
1567
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1450
1568
  let imported = 0, skipped = 0;
1569
+ const pendingMovies = [];
1451
1570
  for (const source of config.sources) {
1452
1571
  if (!(0, import_fs13.existsSync)(source)) {
1453
- spinner_default.warn(`source not found: ${import_cosmetic13.default.blue.encoder(source)}`);
1572
+ spinner_default.warn(`source not found: ${import_termkit14.Color.white.encoder(source)}`);
1454
1573
  continue;
1455
1574
  }
1456
- spinner_default.text = `scanning ${import_cosmetic13.default.blue.encoder(source)}`;
1575
+ spinner_default.text = `scanning ${import_termkit14.Color.white.encoder(source)}`;
1457
1576
  for (const entry of (0, import_fs13.readdirSync)(source)) {
1458
1577
  const entryPath = (0, import_path14.resolve)(source, entry);
1459
1578
  const isDir = (0, import_fs13.lstatSync)(entryPath).isDirectory();
1460
1579
  const ext = entry.match(/([^.]+$)/)?.[0];
1461
1580
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1462
- if (!isDir && !isVideo) {
1581
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1582
+ const isBookDir = isDir && containsBook(entryPath);
1583
+ if (!isDir && !isVideo && !isBook) {
1463
1584
  if (verbose) spinner_default.info(`skipped ${entry}`);
1464
1585
  skipped++;
1465
1586
  continue;
@@ -1467,6 +1588,8 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1467
1588
  let detectedType;
1468
1589
  if (type) {
1469
1590
  detectedType = type;
1591
+ } else if (isBook || isBookDir) {
1592
+ detectedType = "book";
1470
1593
  } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1471
1594
  detectedType = "ps3";
1472
1595
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
@@ -1502,12 +1625,49 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1502
1625
  imported++;
1503
1626
  continue;
1504
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
+ }
1505
1653
  const parsed = parseDownloadName(entry);
1506
1654
  if (!parsed) {
1507
1655
  if (verbose) spinner_default.info(`could not parse: ${entry}`);
1508
1656
  skipped++;
1509
1657
  continue;
1510
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
+ }
1511
1671
  let tmdbId;
1512
1672
  let resolvedTitle = parsed.title;
1513
1673
  let resolvedYear = parsed.year;
@@ -1520,7 +1680,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1520
1680
  resolvedYear = results[0].year ?? parsed.year;
1521
1681
  } else if (results.length > 1) {
1522
1682
  spinner_default.stop();
1523
- const select = new import_termpulse5.Select();
1683
+ const select = new import_termkit14.Select();
1524
1684
  const items = results.map((r) => ({
1525
1685
  label: r.year ? `${r.title} (${r.year})` : r.title,
1526
1686
  description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
@@ -1535,12 +1695,10 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1535
1695
  }
1536
1696
  }
1537
1697
  } else {
1538
- const tmdb = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1539
- if (tmdb) {
1540
- tmdbId = tmdb.id;
1541
- resolvedTitle = tmdb.title;
1542
- resolvedYear = tmdb.year ?? parsed.year;
1543
- }
1698
+ const result = await lookupMovie(parsed);
1699
+ tmdbId = result.tmdbId;
1700
+ resolvedTitle = result.resolvedTitle;
1701
+ resolvedYear = result.resolvedYear;
1544
1702
  }
1545
1703
  }
1546
1704
  if (detectedType === "tv") {
@@ -1566,50 +1724,68 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1566
1724
  }
1567
1725
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1568
1726
  const seasonPath = (0, import_path14.resolve)(showPath, seasonFolderName);
1569
- const videoFile2 = isDir ? findVideo(entryPath) : entry;
1570
- if (!videoFile2) {
1727
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1728
+ if (!videoFile) {
1571
1729
  if (verbose) spinner_default.info(`no video found in: ${entry}`);
1572
1730
  skipped++;
1573
1731
  continue;
1574
1732
  }
1575
- const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
1733
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1576
1734
  const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1577
1735
  const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, resolvedTitle, tmdbEpisodeName ?? void 0);
1578
- const destVideoName2 = `${episodeName}.${videoExt2}`;
1579
- const destVideoPath = (0, import_path14.resolve)(seasonPath, destVideoName2);
1580
- 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;
1581
1739
  if ((0, import_fs13.existsSync)(destVideoPath)) {
1582
- spinner_default.warn(`already exists: ${episodeName}`);
1583
- skipped++;
1584
- 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
+ }
1585
1761
  }
1586
- const dirFiles2 = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
1587
- const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
1588
- const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
1589
- const subtitleSourcePath2 = subtitle2 ? (0, import_path14.resolve)(entryPath, subtitle2) : null;
1590
- 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;
1591
1767
  if (!dryRun) {
1592
1768
  (0, import_fs13.mkdirSync)(seasonPath, { recursive: true });
1593
1769
  let mode = "move";
1594
1770
  if (useHardlink) {
1595
1771
  try {
1596
- if (!sameDev(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1597
- (0, import_fs13.linkSync)(videoSourcePath2, destVideoPath);
1772
+ if (!sameDev(videoSourcePath, seasonPath)) throw new Error("cross-filesystem");
1773
+ (0, import_fs13.linkSync)(videoSourcePath, destVideoPath);
1598
1774
  mode = "hardlink";
1599
1775
  } catch {
1600
1776
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1601
- (0, import_fs13.cpSync)(videoSourcePath2, destVideoPath);
1777
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1602
1778
  mode = "copy";
1603
1779
  }
1604
- 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));
1605
1781
  } else {
1606
- if (sameDev(videoSourcePath2, seasonPath)) {
1607
- (0, import_fs13.renameSync)(videoSourcePath2, destVideoPath);
1782
+ if (sameDev(videoSourcePath, seasonPath)) {
1783
+ (0, import_fs13.renameSync)(videoSourcePath, destVideoPath);
1608
1784
  } else {
1609
- (0, import_fs13.cpSync)(videoSourcePath2, destVideoPath);
1610
- (0, import_fs13.rmSync)(videoSourcePath2);
1785
+ (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1786
+ (0, import_fs13.rmSync)(videoSourcePath);
1611
1787
  }
1612
- 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));
1613
1789
  if (isDir) (0, import_fs13.rmSync)(entryPath, { recursive: true, force: true });
1614
1790
  }
1615
1791
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
@@ -1618,66 +1794,40 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1618
1794
  imported++;
1619
1795
  continue;
1620
1796
  }
1621
- const edition = detectEdition(entry);
1622
- const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1623
- const destFolder = (0, import_path14.resolve)(destRoot, folderName);
1624
- if ((0, import_fs13.existsSync)(destFolder)) {
1625
- spinner_default.warn(`already exists: ${folderName}`);
1797
+ if (await importMovie(entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot)) {
1798
+ imported++;
1799
+ } else {
1626
1800
  skipped++;
1627
- continue;
1628
1801
  }
1629
- const videoFile = isDir ? findVideo(entryPath) : entry;
1630
- if (!videoFile) {
1631
- 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 {
1632
1829
  skipped++;
1633
- continue;
1634
- }
1635
- const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1636
- const destVideoName = `${folderName}.${videoExt}`;
1637
- const videoSourcePath = isDir ? (0, import_path14.resolve)(entryPath, videoFile) : entryPath;
1638
- const dirFiles = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
1639
- const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1640
- const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1641
- const subtitleSourcePath = subtitle ? (0, import_path14.resolve)(entryPath, subtitle) : null;
1642
- const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1643
- if (!dryRun) {
1644
- if (useHardlink) {
1645
- (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1646
- const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1647
- let mode;
1648
- try {
1649
- if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1650
- (0, import_fs13.linkSync)(videoSourcePath, destVideoPath);
1651
- mode = "hardlink";
1652
- } catch {
1653
- spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1654
- (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1655
- mode = "copy";
1656
- }
1657
- if (subtitleSourcePath && destSubtitleName) (0, import_fs13.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(destFolder, destSubtitleName));
1658
- recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1659
- } else {
1660
- if (isDir) {
1661
- const keep = new Set([videoFile, subtitle].filter(Boolean));
1662
- for (const f of dirFiles.filter((f2) => !keep.has(f2))) (0, import_fs13.rmSync)((0, import_path14.resolve)(entryPath, f), { recursive: true, force: true });
1663
- (0, import_fs13.renameSync)(videoSourcePath, (0, import_path14.resolve)(entryPath, destVideoName));
1664
- if (subtitleSourcePath && destSubtitleName) (0, import_fs13.renameSync)(subtitleSourcePath, (0, import_path14.resolve)(entryPath, destSubtitleName));
1665
- moveFolder(entryPath, destFolder);
1666
- } else {
1667
- (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1668
- const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1669
- if (sameDev(videoSourcePath, destRoot)) {
1670
- (0, import_fs13.renameSync)(videoSourcePath, destVideoPath);
1671
- } else {
1672
- (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1673
- (0, import_fs13.rmSync)(videoSourcePath);
1674
- }
1675
- }
1676
- recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1677
- }
1678
1830
  }
1679
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1680
- imported++;
1681
1831
  }
1682
1832
  }
1683
1833
  spinner_default.succeed(`imported ${imported} items`);
@@ -1687,8 +1837,8 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1687
1837
  var scan_default = scan;
1688
1838
 
1689
1839
  // src/actions/shows.ts
1690
- var import_cosmetic14 = __toESM(require("cosmetic"));
1691
1840
  var import_fs14 = require("fs");
1841
+ var import_termkit15 = require("termkit");
1692
1842
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
1693
1843
  var shows = async () => {
1694
1844
  const config = getConfig();
@@ -1699,30 +1849,36 @@ var shows = async () => {
1699
1849
  console.log();
1700
1850
  return;
1701
1851
  }
1702
- const names = allShows.map((s) => s.path.split("/").pop() ?? s.path);
1703
- const titleW = Math.min(50, Math.max(10, ...names.map((n) => n.length)) + 2);
1704
- const divider = "\u2500".repeat(titleW + 32);
1705
1852
  console.log(`
1706
- ${import_cosmetic14.default.yellow.encoder("SHOWS")}${destRoot ? ` ${import_cosmetic14.default.blue.encoder(destRoot)}` : ""} (${allShows.length} registered)`);
1707
- console.log(divider);
1708
- console.log(`${"Title".padEnd(titleW)} ${"Size".padEnd(10)} Linked Status`);
1709
- console.log(divider);
1710
- for (const [i, show] of allShows.entries()) {
1711
- const name = names[i];
1712
- const exists = (0, import_fs14.existsSync)(show.path);
1713
- const size = exists ? formatSize(dirSize(show.path)) : "\u2014";
1714
- let linked2;
1715
- if (show.tmdbId) {
1716
- const url = `https://www.themoviedb.org/tv/${show.tmdbId}`;
1717
- const text = process.stdout.isTTY ? hyperlink(url, "\u2713 tmdb") : "\u2713 tmdb";
1718
- linked2 = import_cosmetic14.default.green.encoder(text);
1719
- } else {
1720
- linked2 = import_cosmetic14.default.red.encoder("\u2717 ");
1853
+ ${import_termkit15.Color.yellow.encoder("SHOWS")}${destRoot ? ` ${import_termkit15.Color.white.encoder(destRoot)}` : ""} (${allShows.length} registered)`);
1854
+ new import_termkit15.Table(
1855
+ allShows.map((show) => ({
1856
+ name: show.path.split("/").pop() ?? show.path,
1857
+ size: (0, import_fs14.existsSync)(show.path) ? formatSize(dirSize(show.path)) : "\u2014",
1858
+ tmdbId: show.tmdbId,
1859
+ ended: show.ended
1860
+ })),
1861
+ {
1862
+ separator: " ",
1863
+ columns: [
1864
+ { key: "name", title: "Title" },
1865
+ { key: "size", title: "Size" },
1866
+ {
1867
+ key: "tmdbId",
1868
+ title: "Linked",
1869
+ value: (v) => {
1870
+ if (v) {
1871
+ const url = `https://www.themoviedb.org/tv/${v}`;
1872
+ const text = process.stdout.isTTY ? hyperlink(url, "\u2713 tmdb") : "\u2713 tmdb";
1873
+ return import_termkit15.Color.green.encoder(text);
1874
+ }
1875
+ return import_termkit15.Color.red.encoder("\u2717");
1876
+ }
1877
+ },
1878
+ { key: "ended", title: "Status", value: (v) => v ? dim("ended") : "active" }
1879
+ ]
1721
1880
  }
1722
- const status = show.ended ? dim("ended") : "active";
1723
- console.log(`${name.padEnd(titleW).substring(0, titleW)} ${size.padEnd(10)} ${linked2} ${status}`);
1724
- }
1725
- console.log(divider);
1881
+ ).print();
1726
1882
  const ended2 = allShows.filter((s) => s.ended).length;
1727
1883
  const linked = allShows.filter((s) => s.tmdbId).length;
1728
1884
  console.log(`${allShows.length} shows \xB7 ${linked} linked \xB7 ${ended2} ended`);
@@ -1731,9 +1887,9 @@ ${import_cosmetic14.default.yellow.encoder("SHOWS")}${destRoot ? ` ${import_cos
1731
1887
  var shows_default = shows;
1732
1888
 
1733
1889
  // src/actions/stats.ts
1734
- var import_cosmetic15 = __toESM(require("cosmetic"));
1735
1890
  var import_fs15 = require("fs");
1736
1891
  var import_path15 = require("path");
1892
+ var import_termkit16 = require("termkit");
1737
1893
  var countVideos = (dir) => {
1738
1894
  let count = 0;
1739
1895
  try {
@@ -1765,36 +1921,38 @@ var countDirs = (dir) => {
1765
1921
  var stats = async () => {
1766
1922
  const config = getConfig();
1767
1923
  const shows2 = getShows();
1768
- const label = (s) => ` ${import_cosmetic15.default.yellow.encoder(s.padEnd(14))}`;
1769
- const val = (n) => String(n);
1770
- console.log("\nLIBRARY STATISTICS\n");
1924
+ const rows = [];
1771
1925
  const movieDest = config.dest.movie;
1772
1926
  if (movieDest && (0, import_fs15.existsSync)(movieDest)) {
1773
- const count = countDirs(movieDest);
1774
- const size = formatSize(dirSize(movieDest));
1775
- console.log(`${label("Movies")}${val(count).padStart(6)} ${size}`);
1927
+ rows.push({ category: "Movies", count: countDirs(movieDest), size: formatSize(dirSize(movieDest)) });
1776
1928
  }
1777
1929
  const tvDest = config.dest.tv;
1778
1930
  if (tvDest && (0, import_fs15.existsSync)(tvDest)) {
1779
- const showCount = shows2.length;
1780
- const episodeCount = countVideos(tvDest);
1781
- const size = formatSize(dirSize(tvDest));
1782
- console.log(`${label("Shows")}${val(showCount).padStart(6)} ${size}`);
1783
- console.log(`${label("Episodes")}${val(episodeCount).padStart(6)}`);
1931
+ rows.push({ category: "Shows", count: shows2.length, size: formatSize(dirSize(tvDest)) });
1932
+ rows.push({ category: "Episodes", count: countVideos(tvDest) });
1784
1933
  }
1785
1934
  const ps3Dest = config.dest.ps3;
1786
1935
  if (ps3Dest && (0, import_fs15.existsSync)(ps3Dest)) {
1787
- const count = countDirs(ps3Dest);
1788
- const size = formatSize(dirSize(ps3Dest));
1789
- console.log(`${label("PS3")}${val(count).padStart(6)} ${size}`);
1936
+ rows.push({ category: "PS3", count: countDirs(ps3Dest), size: formatSize(dirSize(ps3Dest)) });
1790
1937
  }
1938
+ if (rows.length === 0) return;
1939
+ console.log();
1940
+ new import_termkit16.Table(rows, {
1941
+ title: "LIBRARY STATISTICS",
1942
+ separator: " ",
1943
+ columns: [
1944
+ { key: "category", title: "Category" },
1945
+ { key: "count", title: "Count", align: "right", value: (v) => v != null ? String(v) : "" },
1946
+ { key: "size", title: "Size", value: (v) => v ?? "" }
1947
+ ]
1948
+ }).print();
1791
1949
  console.log();
1792
1950
  };
1793
1951
  var stats_default = stats;
1794
1952
 
1795
1953
  // src/actions/undo.ts
1796
- var import_cosmetic16 = __toESM(require("cosmetic"));
1797
1954
  var import_fs16 = require("fs");
1955
+ var import_termkit17 = require("termkit");
1798
1956
  var undo = async () => {
1799
1957
  spinner_default.start();
1800
1958
  const records = getLastSession();
@@ -1806,7 +1964,7 @@ var undo = async () => {
1806
1964
  let undone = 0;
1807
1965
  for (const record of records) {
1808
1966
  (0, import_fs16.renameSync)(record.newPath, record.oldPath);
1809
- spinner_default.succeed(`${import_cosmetic16.default.cyan.encoder(record.newPath)} \u2192 ${import_cosmetic16.default.blue.encoder(record.oldPath)}`);
1967
+ spinner_default.succeed(`${import_termkit17.Color.green.encoder(record.newPath)} \u2192 ${import_termkit17.Color.white.encoder(record.oldPath)}`);
1810
1968
  undone++;
1811
1969
  }
1812
1970
  deleteSession(records[0].sessionId);
@@ -1817,9 +1975,9 @@ var undo_default = undo;
1817
1975
 
1818
1976
  // src/actions/watch.ts
1819
1977
  var import_chokidar = __toESM(require("chokidar"));
1820
- var import_cosmetic17 = __toESM(require("cosmetic"));
1821
1978
  var import_fs17 = require("fs");
1822
1979
  var import_path16 = require("path");
1980
+ var import_termkit18 = require("termkit");
1823
1981
  var sameDev2 = (a, b) => {
1824
1982
  try {
1825
1983
  let bExisting = b;
@@ -1841,6 +1999,10 @@ var findVideo2 = (dir) => (0, import_fs17.readdirSync)(dir).find((f) => {
1841
1999
  const ext = f.match(/([^.]+$)/)?.[0];
1842
2000
  return ext && videoExtensions_default.includes(ext);
1843
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
+ });
1844
2006
  var findSeasonFolder2 = (showPath, season) => {
1845
2007
  if (!(0, import_fs17.existsSync)(showPath)) return null;
1846
2008
  const folders = (0, import_fs17.readdirSync)(showPath).filter((f) => {
@@ -1864,9 +2026,13 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1864
2026
  const isDir = (0, import_fs17.lstatSync)(entryPath).isDirectory();
1865
2027
  const ext = entry.match(/([^.]+$)/)?.[0];
1866
2028
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1867
- 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;
1868
2032
  let detectedType;
1869
- if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
2033
+ if (isBook || isBookDir) {
2034
+ detectedType = "book";
2035
+ } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1870
2036
  detectedType = "ps3";
1871
2037
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1872
2038
  detectedType = "tv";
@@ -1890,7 +2056,28 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1890
2056
  }
1891
2057
  moveItem(entryPath, destPath);
1892
2058
  recordImport(sessionId, entryPath, destPath, "move");
1893
- spinner_default.succeed(`imported ${import_cosmetic17.default.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)}`);
1894
2081
  return;
1895
2082
  }
1896
2083
  const parsed = parseDownloadName(entry);
@@ -1963,7 +2150,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1963
2150
  if (isDir) (0, import_fs17.rmSync)(entryPath, { recursive: true, force: true });
1964
2151
  }
1965
2152
  recordImport(sessionId, entryPath, seasonPath, mode);
1966
- spinner_default.succeed(`imported ${import_cosmetic17.default.cyan.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
2153
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1967
2154
  return;
1968
2155
  }
1969
2156
  const edition = detectEdition(entry);
@@ -2020,7 +2207,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2020
2207
  }
2021
2208
  recordImport(sessionId, entryPath, destFolder, "move");
2022
2209
  }
2023
- spinner_default.succeed(`imported ${import_cosmetic17.default.cyan.encoder(folderName)}`);
2210
+ spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(folderName)}`);
2024
2211
  };
2025
2212
  var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2026
2213
  const config = getConfig();
@@ -2051,35 +2238,45 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2051
2238
  watcher.on("add", handle);
2052
2239
  spinner_default.start();
2053
2240
  spinner_default.succeed(`watching ${config.sources.length} source${config.sources.length !== 1 ? "s" : ""}`);
2054
- for (const s of config.sources) spinner_default.info(` ${import_cosmetic17.default.blue.encoder(s)}`);
2241
+ for (const s of config.sources) spinner_default.info(` ${import_termkit18.Color.white.encoder(s)}`);
2055
2242
  spinner_default.stop();
2056
2243
  process.stdin.resume();
2057
2244
  };
2058
2245
  var watch_default = watch;
2059
2246
 
2060
2247
  // package.json
2061
- var version = "0.1.2";
2248
+ var version = "0.2.1";
2062
2249
 
2063
2250
  // src/program.ts
2064
- var adapt = (fn) => (options) => fn(options);
2065
- var program = (0, import_termkit.command)("reelsort").version(version).description("a cli to manage media").commands([
2066
- (0, import_termkit.command)("config").description("manage configuration").commands([(0, import_termkit.command)("add", "<key> <value>").description("add a value (e.g. add source <dir>)").action(adapt(configAdd)), (0, import_termkit.command)("remove", "<key> <value>").description("remove a value (e.g. remove source <dir>)").action(adapt(configRemove)), (0, import_termkit.command)("set", "<key> <subkey> [value]").description("set a value (e.g. set dest movie <dir>)").action(adapt(configSet)), (0, import_termkit.command)("show").description("show current configuration").action(adapt(configShow))]),
2067
- (0, import_termkit.command)("rename", "<dir>").description("rename media files in directory").options([(0, import_termkit.option)("t", "type", "<type>", "media type: movie, tv, ps3"), (0, import_termkit.option)("v", "verbose", null, "additional output")]).action(adapt(rename_default)),
2068
- (0, import_termkit.command)("reset", "<dir>").description("reset episode names (sxee)").options([(0, import_termkit.option)("d", "double", null, "episodes are doubles")]).action(adapt(reset_default)),
2069
- (0, import_termkit.command)("probe").description("index library metadata using ffprobe").options([(0, import_termkit.option)("t", "type", "<type>", "media type: movie, tv, ps3"), (0, import_termkit.option)("f", "force", null, "re-probe files already indexed"), (0, import_termkit.option)("v", "verbose", null, "show each file as it is probed")]).action(adapt(probe_default)),
2070
- (0, import_termkit.command)("list").description("list library contents").options([(0, import_termkit.option)("t", "type", "<type>", "media type: movie, tv, ps3"), (0, import_termkit.option)("m", "missing-subs", null, "only show items without subtitles"), (0, import_termkit.option)("c", "codec", "<codec>", "filter by codec (e.g. x265)"), (0, import_termkit.option)("r", "resolution", "<res>", "filter by resolution (e.g. 1080p, 4K)"), (0, import_termkit.option)("s", "sort", "<field>", "sort by: year (default), title")]).action(adapt(list_default)),
2071
- (0, import_termkit.command)("watch").description("watch sources and auto-import new media").options([(0, import_termkit.option)("H", "hardlink", null, "hardlink instead of moving (falls back to copy across filesystems)"), (0, import_termkit.option)("v", "verbose", null, "additional output"), (0, import_termkit.option)("a", "auto", null, "auto-register unrecognised TV shows instead of skipping them")]).action(adapt(watch_default)),
2072
- (0, import_termkit.command)("scan").description("import media from configured sources to destinations").options([(0, import_termkit.option)("t", "type", "<type>", "only process this media type: movie, tv, ps3"), (0, import_termkit.option)("H", "hardlink", null, "hardlink instead of moving (falls back to copy across filesystems)"), (0, import_termkit.option)("n", "dry-run", null, "show what would be imported without doing it"), (0, import_termkit.option)("v", "verbose", null, "additional output"), (0, import_termkit.option)("a", "auto", null, "auto-register unrecognised TV shows instead of skipping them")]).action(adapt(scan_default)),
2073
- (0, import_termkit.command)("clean").description("remove source files that have already been imported").options([(0, import_termkit.option)("n", "dry-run", null, "show what would be removed without doing it"), (0, import_termkit.option)("o", "older-than", "<age>", "only clean imports older than age (e.g. 14d, 6h, 30m)")]).action(adapt(clean_default)),
2074
- (0, import_termkit.command)("undo").description("undo the last rename session").action(adapt(undo_default)),
2075
- (0, import_termkit.command)("history").description("show rename or import history").options([(0, import_termkit.option)("l", "limit", "<n>", "number of sessions to show (default 10)"), (0, import_termkit.option)("i", "imports", null, "show import history instead of rename history")]).action(adapt(history_default)),
2076
- (0, import_termkit.command)("diff", "<dir1> <dir2>").description("compare differences between two directories").options([(0, import_termkit.option)("o", "only", "[ext...]", "check specified extensions"), (0, import_termkit.option)("i", "ignore", "[ext...]", "ignore specified extensions")]).action(adapt(differences_default)),
2077
- (0, import_termkit.command)("missing").description("show missing episodes for TV shows (requires TMDb key)").options([(0, import_termkit.option)("s", "show", "<name>", "check a specific show instead of all")]).action(adapt(missing_default)),
2078
- (0, import_termkit.command)("shows").description("list all registered TV shows with TMDb status and size").action(adapt(shows_default)),
2079
- (0, import_termkit.command)("stats").description("show library statistics").action(adapt(stats_default)),
2080
- (0, import_termkit.command)("link").description("link TV shows to TMDb (retroactively or after manual library setup)").options([(0, import_termkit.option)("f", "force", null, "re-link shows that are already linked")]).action(adapt(link_default)),
2081
- (0, import_termkit.command)("ended").description("mark a show as ended (excluded from reelsort missing)").options([(0, import_termkit.option)("r", "remove", null, "restore an ended show to active")]).action(adapt(ended_default)),
2082
- (0, import_termkit.command)("add", "<name>").description("add a TV show by name \u2014 searches TMDb and registers it for scanning").action(adapt(add_default))
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
+ };
2256
+ var { command, option } = import_termkit19.Program;
2257
+ var program = import_termkit19.Program.command("reelsort").version(version).description("a cli to manage media").commands([
2258
+ command("config").description("manage configuration").commands([
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))]),
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))]),
2261
+ command("set", "<key> <subkey> [value]").description("set a value (e.g. set language eng)").action(adapt(configSet)),
2262
+ command("show").description("show current configuration").action(adapt(configShow))
2263
+ ]),
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)),
2265
+ command("reset", "<dir>").description("reset episode names (sxee)").options([option("d", "double", null, "episodes are doubles")]).action(adapt(reset_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)),
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)),
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)),
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)),
2271
+ command("undo").description("undo the last rename session").action(adapt(undo_default)),
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)),
2273
+ command("diff", "<dir1> <dir2>").description("compare differences between two directories").options([option("o", "only", "[ext...]", "check specified extensions"), option("i", "ignore", "[ext...]", "ignore specified extensions")]).action(adapt(differences_default)),
2274
+ command("missing").description("show missing episodes for TV shows (requires TMDb key)").options([option("s", "show", "<name>", "check a specific show instead of all")]).action(adapt(missing_default)),
2275
+ command("shows").description("list all registered TV shows with TMDb status and size").action(adapt(shows_default)),
2276
+ command("stats").description("show library statistics").action(adapt(stats_default)),
2277
+ command("link").description("link TV shows to TMDb (retroactively or after manual library setup)").options([option("f", "force", null, "re-link shows that are already linked")]).action(adapt(link_default)),
2278
+ command("ended").description("mark a show as ended (excluded from reelsort missing)").options([option("r", "remove", null, "restore an ended show to active")]).action(adapt(ended_default)),
2279
+ command("add", "<name>").description("add a TV show by name \u2014 searches TMDb and registers it for scanning").action(adapt(add_default))
2083
2280
  ]);
2084
2281
  var program_default = program;
2085
2282