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/index.js CHANGED
@@ -34,12 +34,12 @@ __export(index_exports, {
34
34
  DEFAULT_MOVIE_FORMAT: () => DEFAULT_MOVIE_FORMAT,
35
35
  DEFAULT_SEASON_FORMAT: () => DEFAULT_SEASON_FORMAT,
36
36
  clean: () => clean_default,
37
- configAdd: () => configAdd,
38
- configRemove: () => configRemove,
39
37
  configSet: () => configSet,
40
38
  configShow: () => configShow,
41
39
  deleteImport: () => deleteImport,
42
40
  deleteSession: () => deleteSession,
41
+ destAdd: () => destAdd,
42
+ destRemove: () => destRemove,
43
43
  detectEdition: () => detectEdition,
44
44
  differences: () => differences_default,
45
45
  formatEpisode: () => formatEpisode,
@@ -64,6 +64,8 @@ __export(index_exports, {
64
64
  reset: () => reset_default,
65
65
  saveConfig: () => saveConfig,
66
66
  scan: () => scan_default,
67
+ sourceAdd: () => sourceAdd,
68
+ sourceRemove: () => sourceRemove,
67
69
  titleCase: () => titleCase_default,
68
70
  undo: () => undo_default,
69
71
  upsertMediaInfo: () => upsertMediaInfo,
@@ -73,8 +75,8 @@ __export(index_exports, {
73
75
  module.exports = __toCommonJS(index_exports);
74
76
 
75
77
  // src/actions/clean.ts
76
- var import_cosmetic = __toESM(require("cosmetic"));
77
78
  var import_fs2 = require("fs");
79
+ var import_termkit2 = require("termkit");
78
80
 
79
81
  // src/db.ts
80
82
  var import_better_sqlite3 = __toESM(require("better-sqlite3"));
@@ -205,28 +207,21 @@ var getHistory = (limit = 10) => {
205
207
  };
206
208
 
207
209
  // src/refs/spinner.ts
208
- var import_termpulse = require("termpulse");
210
+ var import_termkit = require("termkit");
209
211
  var Spinner = class {
210
212
  spinner;
211
- _isSpinning = false;
212
- _text = "";
213
213
  constructor() {
214
- this.spinner = new import_termpulse.Spinner();
214
+ this.spinner = new import_termkit.Spinner();
215
215
  }
216
216
  get text() {
217
- return this._text;
217
+ return this.spinner.text;
218
218
  }
219
219
  set text(t) {
220
- this._text = t;
221
- if (this._isSpinning) this.spinner.message(t);
222
- }
223
- get isSpinning() {
224
- return this._isSpinning;
220
+ this.spinner.text = t;
225
221
  }
226
222
  start(s) {
227
- if (s) this._text = s;
223
+ if (s) this.spinner.text = s;
228
224
  this.spinner.start();
229
- this._isSpinning = true;
230
225
  return this;
231
226
  }
232
227
  info(s) {
@@ -247,8 +242,6 @@ var Spinner = class {
247
242
  }
248
243
  stop() {
249
244
  this.spinner.stop();
250
- this._isSpinning = false;
251
- process.stdin.resume();
252
245
  return this;
253
246
  }
254
247
  };
@@ -287,17 +280,17 @@ var clean = async ({ dryRun, olderThan }) => {
287
280
  continue;
288
281
  }
289
282
  if (dryRun) {
290
- spinner_default.succeed(`[dry] would remove ${import_cosmetic.default.blue.encoder(imp.sourcePath)}`);
283
+ spinner_default.succeed(`[dry] would remove ${import_termkit2.Color.white.encoder(imp.sourcePath)}`);
291
284
  cleaned++;
292
285
  continue;
293
286
  }
294
287
  try {
295
288
  (0, import_fs2.rmSync)(imp.sourcePath, { recursive: true, force: true });
296
289
  deleteImport(imp.id);
297
- spinner_default.succeed(`removed ${import_cosmetic.default.blue.encoder(imp.sourcePath)}`);
290
+ spinner_default.succeed(`removed ${import_termkit2.Color.white.encoder(imp.sourcePath)}`);
298
291
  cleaned++;
299
292
  } catch {
300
- spinner_default.warn(`locked or inaccessible, skipped: ${import_cosmetic.default.blue.encoder(imp.sourcePath)}`);
293
+ spinner_default.warn(`locked or inaccessible, skipped: ${import_termkit2.Color.white.encoder(imp.sourcePath)}`);
301
294
  skipped++;
302
295
  }
303
296
  }
@@ -308,8 +301,8 @@ var clean = async ({ dryRun, olderThan }) => {
308
301
  var clean_default = clean;
309
302
 
310
303
  // src/actions/config.ts
311
- var import_cosmetic2 = __toESM(require("cosmetic"));
312
304
  var import_path3 = require("path");
305
+ var import_termkit3 = require("termkit");
313
306
 
314
307
  // src/config.ts
315
308
  var import_fs3 = require("fs");
@@ -356,38 +349,65 @@ var formatMovieName = (template, title, year, edition) => {
356
349
  };
357
350
 
358
351
  // src/actions/config.ts
359
- var DEST_TYPES = ["movie", "tv", "ps3"];
360
- var configAdd = async ({ key, value }) => {
361
- if (key !== "source") throw new Error(`unknown key '${key}', expected: source`);
362
- const dir = (0, import_path3.resolve)(value);
352
+ var DEST_TYPES = ["movie", "tv", "ps3", "book"];
353
+ var sourceAdd = async ({ dir }) => {
354
+ const resolved = (0, import_path3.resolve)(dir);
363
355
  const config = getConfig();
364
- if (config.sources.includes(dir)) {
356
+ if (config.sources.includes(resolved)) {
365
357
  spinner_default.start();
366
- spinner_default.info(`source already configured: ${import_cosmetic2.default.blue.encoder(dir)}`);
358
+ spinner_default.info(`source already configured: ${import_termkit3.Color.white.encoder(resolved)}`);
367
359
  spinner_default.stop();
368
360
  return;
369
361
  }
370
- config.sources.push(dir);
362
+ config.sources.push(resolved);
371
363
  saveConfig(config);
372
364
  spinner_default.start();
373
- spinner_default.succeed(`added source: ${import_cosmetic2.default.blue.encoder(dir)}`);
365
+ spinner_default.succeed(`added source: ${import_termkit3.Color.white.encoder(resolved)}`);
374
366
  spinner_default.stop();
375
367
  };
376
- var configRemove = async ({ key, value }) => {
377
- if (key !== "source") throw new Error(`unknown key '${key}', expected: source`);
378
- const dir = (0, import_path3.resolve)(value);
368
+ var sourceRemove = async ({ dir }) => {
369
+ const resolved = (0, import_path3.resolve)(dir);
379
370
  const config = getConfig();
380
- const index = config.sources.indexOf(dir);
371
+ const index = config.sources.indexOf(resolved);
381
372
  if (index === -1) {
382
373
  spinner_default.start();
383
- spinner_default.warn(`source not found: ${import_cosmetic2.default.blue.encoder(dir)}`);
374
+ spinner_default.warn(`source not found: ${import_termkit3.Color.white.encoder(resolved)}`);
384
375
  spinner_default.stop();
385
376
  return;
386
377
  }
387
378
  config.sources.splice(index, 1);
388
379
  saveConfig(config);
389
380
  spinner_default.start();
390
- spinner_default.succeed(`removed source: ${import_cosmetic2.default.blue.encoder(dir)}`);
381
+ spinner_default.succeed(`removed source: ${import_termkit3.Color.white.encoder(resolved)}`);
382
+ spinner_default.stop();
383
+ };
384
+ var destAdd = async ({ type, dir }) => {
385
+ if (!DEST_TYPES.includes(type)) {
386
+ throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
387
+ }
388
+ const resolved = (0, import_path3.resolve)(dir);
389
+ const config = getConfig();
390
+ config.dest[type] = resolved;
391
+ saveConfig(config);
392
+ spinner_default.start();
393
+ spinner_default.succeed(`set ${type} destination: ${import_termkit3.Color.green.encoder(resolved)}`);
394
+ spinner_default.stop();
395
+ };
396
+ var destRemove = async ({ type }) => {
397
+ if (!DEST_TYPES.includes(type)) {
398
+ throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
399
+ }
400
+ const config = getConfig();
401
+ if (!config.dest[type]) {
402
+ spinner_default.start();
403
+ spinner_default.warn(`no ${type} destination configured`);
404
+ spinner_default.stop();
405
+ return;
406
+ }
407
+ delete config.dest[type];
408
+ saveConfig(config);
409
+ spinner_default.start();
410
+ spinner_default.succeed(`removed ${type} destination`);
391
411
  spinner_default.stop();
392
412
  };
393
413
  var configSet = async ({ key, subkey, value }) => {
@@ -396,7 +416,7 @@ var configSet = async ({ key, subkey, value }) => {
396
416
  config.language = subkey;
397
417
  saveConfig(config);
398
418
  spinner_default.start();
399
- spinner_default.succeed(`set subtitle language: ${import_cosmetic2.default.cyan.encoder(subkey)}`);
419
+ spinner_default.succeed(`set subtitle language: ${import_termkit3.Color.green.encoder(subkey)}`);
400
420
  spinner_default.stop();
401
421
  return;
402
422
  }
@@ -426,21 +446,11 @@ var configSet = async ({ key, subkey, value }) => {
426
446
  }
427
447
  saveConfig(config);
428
448
  spinner_default.start();
429
- spinner_default.succeed(`set ${subkey} format: ${import_cosmetic2.default.cyan.encoder(value ?? subkey)}`);
449
+ spinner_default.succeed(`set ${subkey} format: ${import_termkit3.Color.green.encoder(value ?? subkey)}`);
430
450
  spinner_default.stop();
431
451
  return;
432
452
  }
433
- if (key !== "dest") throw new Error(`unknown key '${key}', expected: dest, language, tmdb-key, format`);
434
- if (!DEST_TYPES.includes(subkey)) {
435
- throw new Error(`unknown type '${subkey}', expected: ${DEST_TYPES.join(", ")}`);
436
- }
437
- if (!value) throw new Error(`missing path for dest ${subkey}`);
438
- const dir = (0, import_path3.resolve)(value);
439
- config.dest[subkey] = dir;
440
- saveConfig(config);
441
- spinner_default.start();
442
- spinner_default.succeed(`set ${subkey} destination: ${import_cosmetic2.default.cyan.encoder(dir)}`);
443
- spinner_default.stop();
453
+ throw new Error(`unknown key '${key}', expected: language, tmdb-key, format`);
444
454
  };
445
455
  var configShow = async () => {
446
456
  const config = getConfig();
@@ -448,7 +458,7 @@ var configShow = async () => {
448
458
  if (config.sources.length === 0) {
449
459
  console.log(" (none)");
450
460
  } else {
451
- for (const s of config.sources) console.log(` ${import_cosmetic2.default.blue.encoder(s)}`);
461
+ for (const s of config.sources) console.log(` ${import_termkit3.Color.white.encoder(s)}`);
452
462
  }
453
463
  console.log("\nDestinations:");
454
464
  const entries = DEST_TYPES.map((t) => ({ type: t, path: config.dest[t] })).filter((e) => e.path);
@@ -456,26 +466,26 @@ var configShow = async () => {
456
466
  console.log(" (none)");
457
467
  } else {
458
468
  for (const { type, path } of entries) {
459
- console.log(` ${type.padEnd(6)} ${import_cosmetic2.default.cyan.encoder(path)}`);
469
+ console.log(` ${type.padEnd(6)} ${import_termkit3.Color.green.encoder(path)}`);
460
470
  }
461
471
  }
462
472
  console.log(`
463
- Subtitle language: ${import_cosmetic2.default.cyan.encoder(config.language ?? "eng (default)")}`);
464
- console.log(`TMDb API key: ${config.tmdbApiKey ? import_cosmetic2.default.green.encoder("configured") : import_cosmetic2.default.red.encoder("not set")}`);
465
- console.log(`Movie format: ${import_cosmetic2.default.cyan.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
466
- console.log(`Episode format: ${import_cosmetic2.default.cyan.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
467
- console.log(`Season folder: ${import_cosmetic2.default.cyan.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
473
+ Subtitle language: ${import_termkit3.Color.green.encoder(config.language ?? "eng (default)")}`);
474
+ console.log(`TMDb API key: ${config.tmdbApiKey ? import_termkit3.Color.green.encoder("configured") : import_termkit3.Color.red.encoder("not set")}`);
475
+ console.log(`Movie format: ${import_termkit3.Color.green.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
476
+ console.log(`Episode format: ${import_termkit3.Color.green.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
477
+ console.log(`Season folder: ${import_termkit3.Color.green.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
468
478
  console.log();
469
479
  };
470
480
 
471
481
  // src/actions/differences.ts
472
- var import_cosmetic3 = __toESM(require("cosmetic"));
473
482
  var import_fs4 = require("fs");
474
483
  var import_path4 = require("path");
484
+ var import_termkit4 = require("termkit");
475
485
  var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
476
486
  let dir1 = rawDir1;
477
487
  let dir2 = rawDir2;
478
- spinner_default.text = `checking differences between ${import_cosmetic3.default.blue.encoder(dir1)} and ${import_cosmetic3.default.blue.encoder(dir2)}`;
488
+ spinner_default.text = `checking differences between ${import_termkit4.Color.white.encoder(dir1)} and ${import_termkit4.Color.white.encoder(dir2)}`;
479
489
  spinner_default.start();
480
490
  dir1 = (0, import_path4.resolve)(dir1);
481
491
  dir2 = (0, import_path4.resolve)(dir2);
@@ -511,18 +521,18 @@ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
511
521
  removed.push(l);
512
522
  }
513
523
  }
514
- spinner_default.succeed(`checked differences between ${import_cosmetic3.default.blue.encoder(dir1)} and ${import_cosmetic3.default.blue.encoder(dir2)}`);
524
+ spinner_default.succeed(`checked differences between ${import_termkit4.Color.white.encoder(dir1)} and ${import_termkit4.Color.white.encoder(dir2)}`);
515
525
  spinner_default.succeed(`found ${added.length} added files`);
516
526
  spinner_default.succeed(`found ${removed.length} removed files`);
517
527
  spinner_default.stop();
518
- for (const i of added) console.log(`${import_cosmetic3.default.green.encoder("added")} ${i}`);
519
- for (const i of removed) console.log(`${import_cosmetic3.default.red.encoder("removed")} ${i}`);
528
+ for (const i of added) console.log(`${import_termkit4.Color.green.encoder("added")} ${i}`);
529
+ for (const i of removed) console.log(`${import_termkit4.Color.red.encoder("removed")} ${i}`);
520
530
  };
521
531
  var differences_default = differences;
522
532
 
523
533
  // src/actions/history.ts
524
- var import_cosmetic4 = __toESM(require("cosmetic"));
525
534
  var import_path5 = require("path");
535
+ var import_termkit5 = require("termkit");
526
536
  var history = async ({ limit, imports }) => {
527
537
  if (imports) {
528
538
  const sessions = getImportHistory(limit ?? 10);
@@ -534,11 +544,11 @@ var history = async ({ limit, imports }) => {
534
544
  const date = new Date(session.sessionId);
535
545
  const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
536
546
  console.log(`
537
- ${import_cosmetic4.default.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
547
+ ${import_termkit5.Color.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
538
548
  for (const r of session.records) {
539
549
  const src = (0, import_path5.basename)(r.sourcePath);
540
- const dest = import_cosmetic4.default.cyan.encoder(r.destinationPath);
541
- const mode = r.mode !== "move" ? ` ${import_cosmetic4.default.blue.encoder(`[${r.mode}]`)}` : "";
550
+ const dest = import_termkit5.Color.green.encoder(r.destinationPath);
551
+ const mode = r.mode !== "move" ? ` ${import_termkit5.Color.white.encoder(`[${r.mode}]`)}` : "";
542
552
  console.log(` ${src} \u2192 ${dest}${mode}`);
543
553
  }
544
554
  }
@@ -553,11 +563,11 @@ ${import_cosmetic4.default.yellow.encoder(label)} (${session.records.length} it
553
563
  const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
554
564
  const folders = session.records.filter((r) => (0, import_path5.extname)(r.newPath) === "");
555
565
  console.log(`
556
- ${import_cosmetic4.default.yellow.encoder(label)} (${folders.length} item${folders.length !== 1 ? "s" : ""})`);
566
+ ${import_termkit5.Color.yellow.encoder(label)} (${folders.length} item${folders.length !== 1 ? "s" : ""})`);
557
567
  for (const r of folders) {
558
568
  const oldName = (0, import_path5.basename)(r.oldPath);
559
569
  const newName = (0, import_path5.basename)(r.newPath);
560
- console.log(` ${import_cosmetic4.default.blue.encoder(oldName)} \u2192 ${import_cosmetic4.default.cyan.encoder(newName)}`);
570
+ console.log(` ${import_termkit5.Color.white.encoder(oldName)} \u2192 ${import_termkit5.Color.green.encoder(newName)}`);
561
571
  }
562
572
  }
563
573
  }
@@ -566,9 +576,9 @@ ${import_cosmetic4.default.yellow.encoder(label)} (${folders.length} item${fold
566
576
  var history_default = history;
567
577
 
568
578
  // src/actions/list.ts
569
- var import_cosmetic5 = __toESM(require("cosmetic"));
570
579
  var import_fs6 = require("fs");
571
580
  var import_path7 = require("path");
581
+ var import_termkit6 = require("termkit");
572
582
 
573
583
  // src/helpers/dirSize.ts
574
584
  var import_fs5 = require("fs");
@@ -700,7 +710,6 @@ var parseLibraryFolder = (name) => {
700
710
  if (match) return { title: match[1].trim(), year: parseInt(match[2]) };
701
711
  return { title: name };
702
712
  };
703
- var col = (s, width) => s.padEnd(width).substring(0, width);
704
713
  var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter, sort }) => {
705
714
  const config = getConfig();
706
715
  const types = (type ? [type] : DEST_TYPES2).filter((t) => config.dest[t]);
@@ -709,7 +718,7 @@ var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter
709
718
  const destRoot = config.dest[t];
710
719
  if (!(0, import_fs6.existsSync)(destRoot)) {
711
720
  console.log(`
712
- ${t.toUpperCase()} ${import_cosmetic5.default.blue.encoder(destRoot)} (not found)`);
721
+ ${t.toUpperCase()} ${import_termkit6.Color.white.encoder(destRoot)} (not found)`);
713
722
  continue;
714
723
  }
715
724
  const folders = (0, import_fs6.readdirSync)(destRoot).filter((f) => {
@@ -739,18 +748,29 @@ ${t.toUpperCase()} ${import_cosmetic5.default.blue.encoder(destRoot)} (not fou
739
748
  const yearDiff = (b.year ?? 0) - (a.year ?? 0);
740
749
  return yearDiff !== 0 ? yearDiff : a.title.localeCompare(b.title);
741
750
  });
742
- const titleW = Math.min(50, Math.max(10, ...filtered.map((e) => e.title.length)) + 2);
743
- const divider = "\u2500".repeat(titleW + 44);
744
751
  console.log(`
745
- ${import_cosmetic5.default.yellow.encoder(t.toUpperCase())} ${import_cosmetic5.default.blue.encoder(destRoot)}`);
746
- console.log(divider);
747
- console.log(`${"Title".padEnd(titleW)} ${"Year".padEnd(6)} ${"Res".padEnd(6)} ${"Codec".padEnd(6)} ${"Size".padEnd(10)} Sub`);
748
- console.log(divider);
749
- for (const e of filtered) {
750
- const sub = e.hasSub ? import_cosmetic5.default.green.encoder("\u2713") : import_cosmetic5.default.red.encoder("\u2717");
751
- 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}`);
752
- }
753
- console.log(divider);
752
+ ${import_termkit6.Color.yellow.encoder(t.toUpperCase())} ${import_termkit6.Color.white.encoder(destRoot)}`);
753
+ new import_termkit6.Table(
754
+ filtered.map((e) => ({
755
+ title: e.title,
756
+ year: e.year,
757
+ resolution: e.resolution,
758
+ codec: e.codec,
759
+ size: e.size,
760
+ sub: e.hasSub
761
+ })),
762
+ {
763
+ separator: " ",
764
+ columns: [
765
+ { key: "title", title: "Title" },
766
+ { key: "year", title: "Year", align: "right", value: (v) => v != null ? String(v) : "\u2014" },
767
+ { key: "resolution", title: "Res", value: (v) => v ?? "\u2014" },
768
+ { key: "codec", title: "Codec", value: (v) => v ?? "\u2014" },
769
+ { key: "size", title: "Size" },
770
+ { key: "sub", title: "Sub", value: (v) => v ? import_termkit6.Color.green.encoder("\u2713") : import_termkit6.Color.red.encoder("\u2717") }
771
+ ]
772
+ }
773
+ ).print();
754
774
  console.log(`${filtered.length} of ${entries.length} item${entries.length !== 1 ? "s" : ""}`);
755
775
  }
756
776
  console.log();
@@ -759,9 +779,9 @@ var list_default = list;
759
779
 
760
780
  // src/actions/probe.ts
761
781
  var import_child_process = require("child_process");
762
- var import_cosmetic6 = __toESM(require("cosmetic"));
763
782
  var import_fs7 = require("fs");
764
783
  var import_path8 = require("path");
784
+ var import_termkit7 = require("termkit");
765
785
  var DEST_TYPES3 = ["movie", "tv", "ps3"];
766
786
  var CODEC_MAP2 = {
767
787
  hevc: "x265",
@@ -837,7 +857,7 @@ var probe = async ({ type, force, verbose }) => {
837
857
  for (const t of types) {
838
858
  const destRoot = config.dest[t];
839
859
  if (!(0, import_fs7.existsSync)(destRoot)) continue;
840
- spinner_default.text = `scanning ${import_cosmetic6.default.blue.encoder(destRoot)}`;
860
+ spinner_default.text = `scanning ${import_termkit7.Color.white.encoder(destRoot)}`;
841
861
  const files = walkVideoFiles(destRoot);
842
862
  for (const filePath of files) {
843
863
  if (!force && getMediaInfo(filePath)) {
@@ -845,7 +865,7 @@ var probe = async ({ type, force, verbose }) => {
845
865
  skipped++;
846
866
  continue;
847
867
  }
848
- spinner_default.text = `probing ${import_cosmetic6.default.blue.encoder(filePath)}`;
868
+ spinner_default.text = `probing ${import_termkit7.Color.white.encoder(filePath)}`;
849
869
  const result = runFfprobe(filePath);
850
870
  if (!result) {
851
871
  if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
@@ -865,10 +885,10 @@ var probe = async ({ type, force, verbose }) => {
865
885
  var probe_default = probe;
866
886
 
867
887
  // src/actions/rename.ts
868
- var import_cosmetic7 = __toESM(require("cosmetic"));
869
888
  var import_fs8 = require("fs");
870
889
  var import_path9 = require("path");
871
890
  var import_rimraf = require("rimraf");
891
+ var import_termkit8 = require("termkit");
872
892
 
873
893
  // src/helpers/findSubtitle.ts
874
894
  var LANGUAGE_ALIASES = {
@@ -929,13 +949,13 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
929
949
  const config = getConfig();
930
950
  const language = config.language ?? "eng";
931
951
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
932
- spinner_default.text = `renaming in ${import_cosmetic7.default.blue.encoder(dir)}`;
952
+ spinner_default.text = `renaming in ${import_termkit8.Color.white.encoder(dir)}`;
933
953
  spinner_default.start();
934
954
  if (!(0, import_fs8.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
935
955
  const list2 = (0, import_fs8.readdirSync)(dir);
936
956
  let renamed = 0, removed = 0, skipped = 0;
937
957
  for (const [index, entry] of list2.entries()) {
938
- spinner_default.text = `renaming in ${import_cosmetic7.default.blue.encoder(dir)} ${index + 1}/${list2.length}`;
958
+ spinner_default.text = `renaming in ${import_termkit8.Color.white.encoder(dir)} ${index + 1}/${list2.length}`;
939
959
  if (!(0, import_fs8.lstatSync)((0, import_path9.resolve)(dir, entry)).isDirectory()) {
940
960
  if (verbose) spinner_default.info(`skipped ${entry}`);
941
961
  skipped++;
@@ -1020,18 +1040,18 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1020
1040
  spinner_default.succeed(`renamed ${renamed} files`);
1021
1041
  if (removed) spinner_default.info(`removed ${removed} files`);
1022
1042
  spinner_default.info(`skipped ${skipped} files`);
1023
- spinner_default.succeed(`done in ${import_cosmetic7.default.cyan.encoder(dir)}`);
1043
+ spinner_default.succeed(`done in ${import_termkit8.Color.green.encoder(dir)}`);
1024
1044
  spinner_default.stop();
1025
1045
  };
1026
1046
  var rename_default = rename;
1027
1047
 
1028
1048
  // src/actions/reset.ts
1029
- var import_cosmetic8 = __toESM(require("cosmetic"));
1030
1049
  var import_fs9 = require("fs");
1031
1050
  var import_path10 = require("path");
1051
+ var import_termkit9 = require("termkit");
1032
1052
  var reset = async ({ dir: inputDir, double }) => {
1033
1053
  let dir = inputDir;
1034
- spinner_default.text = `resetting episodes in ${import_cosmetic8.default.blue.encoder(dir)}`;
1054
+ spinner_default.text = `resetting episodes in ${import_termkit9.Color.white.encoder(dir)}`;
1035
1055
  spinner_default.start();
1036
1056
  dir = (0, import_path10.resolve)(dir);
1037
1057
  if (!(0, import_fs9.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
@@ -1060,7 +1080,7 @@ var reset = async ({ dir: inputDir, double }) => {
1060
1080
  const episodeFormat = getConfig().format?.episode;
1061
1081
  let renamed = 0, skipped = other.length;
1062
1082
  for (const [index, i] of sublist.entries()) {
1063
- spinner_default.text = `resetting episodes in ${import_cosmetic8.default.blue.encoder(dir)} ${index}/${list2.length}`;
1083
+ spinner_default.text = `resetting episodes in ${import_termkit9.Color.white.encoder(dir)} ${index}/${list2.length}`;
1064
1084
  const ext = i.match(/([^.]+$)/)?.[0];
1065
1085
  const episode = double ? index * 2 + 1 : index + 1;
1066
1086
  const name = `${formatEpisode(seasonNum, episode, episodeFormat, double, showTitle)}.${ext}`;
@@ -1073,16 +1093,15 @@ var reset = async ({ dir: inputDir, double }) => {
1073
1093
  }
1074
1094
  spinner_default.succeed(`renamed ${renamed} files`);
1075
1095
  spinner_default.info(`skipped ${skipped} files`);
1076
- spinner_default.succeed(`done in ${import_cosmetic8.default.cyan.encoder(dir)}`);
1096
+ spinner_default.succeed(`done in ${import_termkit9.Color.green.encoder(dir)}`);
1077
1097
  spinner_default.stop();
1078
1098
  };
1079
1099
  var reset_default = reset;
1080
1100
 
1081
1101
  // src/actions/scan.ts
1082
- var import_cosmetic9 = __toESM(require("cosmetic"));
1083
1102
  var import_fs10 = require("fs");
1084
1103
  var import_path11 = require("path");
1085
- var import_termpulse2 = require("termpulse");
1104
+ var import_termkit10 = require("termkit");
1086
1105
 
1087
1106
  // src/helpers/detectEdition.ts
1088
1107
  var EDITIONS = [
@@ -1155,18 +1174,17 @@ var searchMovie = async (title, year, apiKey) => {
1155
1174
  if (year) url.searchParams.set("year", String(year));
1156
1175
  try {
1157
1176
  const res = await fetch(url.toString());
1158
- if (!res.ok) return null;
1177
+ if (!res.ok) return [];
1159
1178
  const data = await res.json();
1160
- const first = data.results[0];
1161
- if (!first) return null;
1162
- return {
1163
- id: first.id,
1164
- title: first.title,
1165
- year: first.release_date ? parseInt(first.release_date.slice(0, 4)) : void 0,
1166
- url: `${TMDB_WEB}/movie/${first.id}`
1167
- };
1179
+ return data.results.slice(0, 5).map((r) => ({
1180
+ id: r.id,
1181
+ title: r.title,
1182
+ year: r.release_date ? parseInt(r.release_date.slice(0, 4)) : void 0,
1183
+ overview: r.overview || void 0,
1184
+ url: `${TMDB_WEB}/movie/${r.id}`
1185
+ }));
1168
1186
  } catch {
1169
- return null;
1187
+ return [];
1170
1188
  }
1171
1189
  };
1172
1190
  var getEpisodeName = async (seriesId, season, episode, apiKey) => {
@@ -1201,6 +1219,9 @@ var searchTv = async (title, apiKey) => {
1201
1219
  }
1202
1220
  };
1203
1221
 
1222
+ // src/refs/bookExtensions.json
1223
+ var bookExtensions_default = ["epub", "mobi", "azw3", "azw"];
1224
+
1204
1225
  // src/actions/scan.ts
1205
1226
  var sameDev = (a, b) => {
1206
1227
  try {
@@ -1223,6 +1244,10 @@ var findVideo = (dir) => (0, import_fs10.readdirSync)(dir).find((f) => {
1223
1244
  const ext = f.match(/([^.]+$)/)?.[0];
1224
1245
  return ext && videoExtensions_default.includes(ext);
1225
1246
  }) ?? null;
1247
+ var containsBook = (dir) => (0, import_fs10.readdirSync)(dir).some((f) => {
1248
+ const ext = f.match(/([^.]+$)/)?.[0];
1249
+ return ext && bookExtensions_default.includes(ext);
1250
+ });
1226
1251
  var findSeasonFolder = (showPath, season) => {
1227
1252
  if (!(0, import_fs10.existsSync)(showPath)) return null;
1228
1253
  const folders = (0, import_fs10.readdirSync)(showPath).filter((f) => {
@@ -1237,27 +1262,128 @@ var findSeasonFolder = (showPath, season) => {
1237
1262
  return match && parseInt(match[1]) === season;
1238
1263
  }) ?? null;
1239
1264
  };
1240
- var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1265
+ var classifyMovieConfidence = (entry) => {
1266
+ if (/^\[(?:Team|Group)\s/i.test(entry)) return "skip";
1267
+ if (/\bepisodes?\s+\d+[-–]\d+/i.test(entry)) return "skip";
1268
+ if (/\b(?:patch|keygen|crack)\b|\bkeys?\s*\{/i.test(entry)) return "skip";
1269
+ if (/\[YTS[.\-]/i.test(entry)) return "auto";
1270
+ if (/\(\d{4}\)/.test(entry) && /\[(?:2160p|1080p|720p|480p|576p|BluRay|BDRip|BDRemux|WEBRip|WEB-DL|HDRip|DVDRip|HDTV)/i.test(entry)) return "auto";
1271
+ if (/\.\d{4}\..*?(?:2160p|1080p|720p|BluRay|WEBRip|WEB-DL|HDTV)/i.test(entry)) return "auto";
1272
+ return "ambiguous";
1273
+ };
1274
+ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, interactive }) => {
1241
1275
  const config = getConfig();
1242
1276
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1243
1277
  const language = config.language ?? "eng";
1244
1278
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1245
1279
  const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
1280
+ const lookupMovie = async (parsed) => {
1281
+ let tmdbId;
1282
+ let resolvedTitle = parsed.title;
1283
+ let resolvedYear = parsed.year;
1284
+ if (config.tmdbApiKey) {
1285
+ const results = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1286
+ if (results.length === 1) {
1287
+ tmdbId = results[0].id;
1288
+ resolvedTitle = results[0].title;
1289
+ resolvedYear = results[0].year ?? parsed.year;
1290
+ } else if (results.length > 1) {
1291
+ spinner_default.stop();
1292
+ const select = new import_termkit10.Select();
1293
+ const items = results.map((r) => ({
1294
+ label: r.year ? `${r.title} (${r.year})` : r.title,
1295
+ description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
1296
+ ...r
1297
+ }));
1298
+ const picked = await select.ask(`Multiple movies found for "${parsed.title}":`, items);
1299
+ spinner_default.start();
1300
+ if (picked) {
1301
+ tmdbId = picked.id;
1302
+ resolvedTitle = picked.title;
1303
+ resolvedYear = picked.year ?? parsed.year;
1304
+ }
1305
+ }
1306
+ }
1307
+ return { tmdbId, resolvedTitle, resolvedYear };
1308
+ };
1309
+ const importMovie = async (entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot) => {
1310
+ const edition = detectEdition(entry);
1311
+ const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1312
+ const destFolder = (0, import_path11.resolve)(destRoot, folderName);
1313
+ if ((0, import_fs10.existsSync)(destFolder)) {
1314
+ spinner_default.warn(`already exists: ${folderName}`);
1315
+ return false;
1316
+ }
1317
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1318
+ if (!videoFile) {
1319
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1320
+ return false;
1321
+ }
1322
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1323
+ const destVideoName = `${folderName}.${videoExt}`;
1324
+ const videoSourcePath = isDir ? (0, import_path11.resolve)(entryPath, videoFile) : entryPath;
1325
+ const dirFiles = isDir ? (0, import_fs10.readdirSync)(entryPath) : [];
1326
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1327
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1328
+ const subtitleSourcePath = subtitle ? (0, import_path11.resolve)(entryPath, subtitle) : null;
1329
+ const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1330
+ if (!dryRun) {
1331
+ if (useHardlink) {
1332
+ (0, import_fs10.mkdirSync)(destFolder, { recursive: true });
1333
+ const destVideoPath = (0, import_path11.resolve)(destFolder, destVideoName);
1334
+ let mode;
1335
+ try {
1336
+ if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1337
+ (0, import_fs10.linkSync)(videoSourcePath, destVideoPath);
1338
+ mode = "hardlink";
1339
+ } catch {
1340
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1341
+ (0, import_fs10.cpSync)(videoSourcePath, destVideoPath);
1342
+ mode = "copy";
1343
+ }
1344
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs10.cpSync)(subtitleSourcePath, (0, import_path11.resolve)(destFolder, destSubtitleName));
1345
+ recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1346
+ } else {
1347
+ if (isDir) {
1348
+ const keep = new Set([videoFile, subtitle].filter(Boolean));
1349
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) (0, import_fs10.rmSync)((0, import_path11.resolve)(entryPath, f), { recursive: true, force: true });
1350
+ (0, import_fs10.renameSync)(videoSourcePath, (0, import_path11.resolve)(entryPath, destVideoName));
1351
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs10.renameSync)(subtitleSourcePath, (0, import_path11.resolve)(entryPath, destSubtitleName));
1352
+ moveFolder(entryPath, destFolder);
1353
+ } else {
1354
+ (0, import_fs10.mkdirSync)(destFolder, { recursive: true });
1355
+ const destVideoPath = (0, import_path11.resolve)(destFolder, destVideoName);
1356
+ if (sameDev(videoSourcePath, destRoot)) {
1357
+ (0, import_fs10.renameSync)(videoSourcePath, destVideoPath);
1358
+ } else {
1359
+ (0, import_fs10.cpSync)(videoSourcePath, destVideoPath);
1360
+ (0, import_fs10.rmSync)(videoSourcePath);
1361
+ }
1362
+ }
1363
+ recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1364
+ }
1365
+ }
1366
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1367
+ return true;
1368
+ };
1246
1369
  spinner_default.start();
1247
1370
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1248
1371
  let imported = 0, skipped = 0;
1372
+ const pendingMovies = [];
1249
1373
  for (const source of config.sources) {
1250
1374
  if (!(0, import_fs10.existsSync)(source)) {
1251
- spinner_default.warn(`source not found: ${import_cosmetic9.default.blue.encoder(source)}`);
1375
+ spinner_default.warn(`source not found: ${import_termkit10.Color.white.encoder(source)}`);
1252
1376
  continue;
1253
1377
  }
1254
- spinner_default.text = `scanning ${import_cosmetic9.default.blue.encoder(source)}`;
1378
+ spinner_default.text = `scanning ${import_termkit10.Color.white.encoder(source)}`;
1255
1379
  for (const entry of (0, import_fs10.readdirSync)(source)) {
1256
1380
  const entryPath = (0, import_path11.resolve)(source, entry);
1257
1381
  const isDir = (0, import_fs10.lstatSync)(entryPath).isDirectory();
1258
1382
  const ext = entry.match(/([^.]+$)/)?.[0];
1259
1383
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1260
- if (!isDir && !isVideo) {
1384
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1385
+ const isBookDir = isDir && containsBook(entryPath);
1386
+ if (!isDir && !isVideo && !isBook) {
1261
1387
  if (verbose) spinner_default.info(`skipped ${entry}`);
1262
1388
  skipped++;
1263
1389
  continue;
@@ -1265,6 +1391,8 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1265
1391
  let detectedType;
1266
1392
  if (type) {
1267
1393
  detectedType = type;
1394
+ } else if (isBook || isBookDir) {
1395
+ detectedType = "book";
1268
1396
  } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1269
1397
  detectedType = "ps3";
1270
1398
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
@@ -1300,12 +1428,49 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1300
1428
  imported++;
1301
1429
  continue;
1302
1430
  }
1431
+ if (detectedType === "book") {
1432
+ const destPath = (0, import_path11.resolve)(destRoot, entry);
1433
+ if ((0, import_fs10.existsSync)(destPath)) {
1434
+ spinner_default.warn(`already exists: ${entry}`);
1435
+ skipped++;
1436
+ continue;
1437
+ }
1438
+ if (!dryRun) {
1439
+ if (isDir || isBookDir) {
1440
+ moveFolder(entryPath, destPath);
1441
+ } else {
1442
+ (0, import_fs10.mkdirSync)(destRoot, { recursive: true });
1443
+ if (sameDev(entryPath, destRoot)) {
1444
+ (0, import_fs10.renameSync)(entryPath, destPath);
1445
+ } else {
1446
+ (0, import_fs10.cpSync)(entryPath, destPath);
1447
+ (0, import_fs10.rmSync)(entryPath);
1448
+ }
1449
+ }
1450
+ recordImport(sessionId, entryPath, destPath, "move");
1451
+ }
1452
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${entry}`);
1453
+ imported++;
1454
+ continue;
1455
+ }
1303
1456
  const parsed = parseDownloadName(entry);
1304
1457
  if (!parsed) {
1305
1458
  if (verbose) spinner_default.info(`could not parse: ${entry}`);
1306
1459
  skipped++;
1307
1460
  continue;
1308
1461
  }
1462
+ if (detectedType === "movie") {
1463
+ const confidence = classifyMovieConfidence(entry);
1464
+ if (confidence === "skip") {
1465
+ if (verbose) spinner_default.info(`skipped (uncertain): ${entry}`);
1466
+ skipped++;
1467
+ continue;
1468
+ }
1469
+ if (confidence === "ambiguous") {
1470
+ pendingMovies.push({ entry, entryPath, isDir, parsed, destRoot });
1471
+ continue;
1472
+ }
1473
+ }
1309
1474
  let tmdbId;
1310
1475
  let resolvedTitle = parsed.title;
1311
1476
  let resolvedYear = parsed.year;
@@ -1318,7 +1483,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1318
1483
  resolvedYear = results[0].year ?? parsed.year;
1319
1484
  } else if (results.length > 1) {
1320
1485
  spinner_default.stop();
1321
- const select = new import_termpulse2.Select();
1486
+ const select = new import_termkit10.Select();
1322
1487
  const items = results.map((r) => ({
1323
1488
  label: r.year ? `${r.title} (${r.year})` : r.title,
1324
1489
  description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
@@ -1333,12 +1498,10 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1333
1498
  }
1334
1499
  }
1335
1500
  } else {
1336
- const tmdb = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1337
- if (tmdb) {
1338
- tmdbId = tmdb.id;
1339
- resolvedTitle = tmdb.title;
1340
- resolvedYear = tmdb.year ?? parsed.year;
1341
- }
1501
+ const result = await lookupMovie(parsed);
1502
+ tmdbId = result.tmdbId;
1503
+ resolvedTitle = result.resolvedTitle;
1504
+ resolvedYear = result.resolvedYear;
1342
1505
  }
1343
1506
  }
1344
1507
  if (detectedType === "tv") {
@@ -1364,50 +1527,68 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1364
1527
  }
1365
1528
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1366
1529
  const seasonPath = (0, import_path11.resolve)(showPath, seasonFolderName);
1367
- const videoFile2 = isDir ? findVideo(entryPath) : entry;
1368
- if (!videoFile2) {
1530
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1531
+ if (!videoFile) {
1369
1532
  if (verbose) spinner_default.info(`no video found in: ${entry}`);
1370
1533
  skipped++;
1371
1534
  continue;
1372
1535
  }
1373
- const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
1536
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1374
1537
  const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1375
1538
  const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, resolvedTitle, tmdbEpisodeName ?? void 0);
1376
- const destVideoName2 = `${episodeName}.${videoExt2}`;
1377
- const destVideoPath = (0, import_path11.resolve)(seasonPath, destVideoName2);
1378
- const videoSourcePath2 = isDir ? (0, import_path11.resolve)(entryPath, videoFile2) : entryPath;
1539
+ const destVideoName = `${episodeName}.${videoExt}`;
1540
+ const destVideoPath = (0, import_path11.resolve)(seasonPath, destVideoName);
1541
+ const videoSourcePath = isDir ? (0, import_path11.resolve)(entryPath, videoFile) : entryPath;
1379
1542
  if ((0, import_fs10.existsSync)(destVideoPath)) {
1380
- spinner_default.warn(`already exists: ${episodeName}`);
1381
- skipped++;
1382
- continue;
1543
+ let shouldReplace = force;
1544
+ if (!shouldReplace && interactive) {
1545
+ spinner_default.stop();
1546
+ const select = new import_termkit10.Select();
1547
+ const picked = await select.ask(`Already exists \u2014 replace?`, [
1548
+ { label: `${showFolderName} / ${seasonFolderName} / ${episodeName}`, value: "replace" },
1549
+ { label: "Skip", value: "skip" }
1550
+ ]);
1551
+ spinner_default.start();
1552
+ shouldReplace = picked?.value === "replace";
1553
+ }
1554
+ if (!shouldReplace) {
1555
+ spinner_default.warn(`already exists: ${episodeName}`);
1556
+ skipped++;
1557
+ continue;
1558
+ }
1559
+ if (!dryRun) {
1560
+ for (const f of (0, import_fs10.readdirSync)(seasonPath)) {
1561
+ if (f.startsWith(`${episodeName}.`)) (0, import_fs10.rmSync)((0, import_path11.resolve)(seasonPath, f));
1562
+ }
1563
+ }
1383
1564
  }
1384
- const dirFiles2 = isDir ? (0, import_fs10.readdirSync)(entryPath) : [];
1385
- const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
1386
- const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
1387
- const subtitleSourcePath2 = subtitle2 ? (0, import_path11.resolve)(entryPath, subtitle2) : null;
1388
- const destSubtitleName2 = subtitle2 && subtitleExt2 ? `${episodeName}.${subtitleExt2}` : null;
1565
+ const dirFiles = isDir ? (0, import_fs10.readdirSync)(entryPath) : [];
1566
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1567
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1568
+ const subtitleSourcePath = subtitle ? (0, import_path11.resolve)(entryPath, subtitle) : null;
1569
+ const destSubtitleName = subtitle && subtitleExt ? `${episodeName}.${subtitleExt}` : null;
1389
1570
  if (!dryRun) {
1390
1571
  (0, import_fs10.mkdirSync)(seasonPath, { recursive: true });
1391
1572
  let mode = "move";
1392
1573
  if (useHardlink) {
1393
1574
  try {
1394
- if (!sameDev(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1395
- (0, import_fs10.linkSync)(videoSourcePath2, destVideoPath);
1575
+ if (!sameDev(videoSourcePath, seasonPath)) throw new Error("cross-filesystem");
1576
+ (0, import_fs10.linkSync)(videoSourcePath, destVideoPath);
1396
1577
  mode = "hardlink";
1397
1578
  } catch {
1398
1579
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1399
- (0, import_fs10.cpSync)(videoSourcePath2, destVideoPath);
1580
+ (0, import_fs10.cpSync)(videoSourcePath, destVideoPath);
1400
1581
  mode = "copy";
1401
1582
  }
1402
- if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs10.cpSync)(subtitleSourcePath2, (0, import_path11.resolve)(seasonPath, destSubtitleName2));
1583
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs10.cpSync)(subtitleSourcePath, (0, import_path11.resolve)(seasonPath, destSubtitleName));
1403
1584
  } else {
1404
- if (sameDev(videoSourcePath2, seasonPath)) {
1405
- (0, import_fs10.renameSync)(videoSourcePath2, destVideoPath);
1585
+ if (sameDev(videoSourcePath, seasonPath)) {
1586
+ (0, import_fs10.renameSync)(videoSourcePath, destVideoPath);
1406
1587
  } else {
1407
- (0, import_fs10.cpSync)(videoSourcePath2, destVideoPath);
1408
- (0, import_fs10.rmSync)(videoSourcePath2);
1588
+ (0, import_fs10.cpSync)(videoSourcePath, destVideoPath);
1589
+ (0, import_fs10.rmSync)(videoSourcePath);
1409
1590
  }
1410
- if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs10.renameSync)(subtitleSourcePath2, (0, import_path11.resolve)(seasonPath, destSubtitleName2));
1591
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs10.renameSync)(subtitleSourcePath, (0, import_path11.resolve)(seasonPath, destSubtitleName));
1411
1592
  if (isDir) (0, import_fs10.rmSync)(entryPath, { recursive: true, force: true });
1412
1593
  }
1413
1594
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
@@ -1416,66 +1597,40 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1416
1597
  imported++;
1417
1598
  continue;
1418
1599
  }
1419
- const edition = detectEdition(entry);
1420
- const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1421
- const destFolder = (0, import_path11.resolve)(destRoot, folderName);
1422
- if ((0, import_fs10.existsSync)(destFolder)) {
1423
- spinner_default.warn(`already exists: ${folderName}`);
1600
+ if (await importMovie(entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot)) {
1601
+ imported++;
1602
+ } else {
1424
1603
  skipped++;
1425
- continue;
1426
1604
  }
1427
- const videoFile = isDir ? findVideo(entryPath) : entry;
1428
- if (!videoFile) {
1429
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1605
+ }
1606
+ }
1607
+ if (pendingMovies.length > 0) {
1608
+ spinner_default.warn(`${pendingMovies.length} uncertain movie match${pendingMovies.length > 1 ? "es" : ""} skipped \u2014 use -i to review or -f to import all`);
1609
+ for (const p of pendingMovies) spinner_default.info(` ? ${p.entry.replace(/\/$/, "")}`);
1610
+ let toProcess = [];
1611
+ if (interactive) {
1612
+ spinner_default.stop();
1613
+ const ms = new import_termkit10.MultiSelect({ allowSkip: true, search: true, maxHeight: 20 });
1614
+ const items = pendingMovies.map((p) => ({
1615
+ label: p.entry.replace(/\/$/, ""),
1616
+ description: p.parsed.year ? `parsed: ${p.parsed.title} \xB7 ${p.parsed.year}` : `parsed: ${p.parsed.title}`,
1617
+ ...p
1618
+ }));
1619
+ toProcess = await ms.ask("Select entries to import as movies:", items) ?? [];
1620
+ spinner_default.start();
1621
+ skipped += pendingMovies.length - toProcess.length;
1622
+ } else if (force) {
1623
+ toProcess = pendingMovies;
1624
+ } else {
1625
+ skipped += pendingMovies.length;
1626
+ }
1627
+ for (const p of toProcess) {
1628
+ const { tmdbId, resolvedTitle, resolvedYear } = await lookupMovie(p.parsed);
1629
+ if (await importMovie(p.entry, p.entryPath, p.isDir, resolvedTitle, resolvedYear, tmdbId, p.destRoot)) {
1630
+ imported++;
1631
+ } else {
1430
1632
  skipped++;
1431
- continue;
1432
- }
1433
- const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1434
- const destVideoName = `${folderName}.${videoExt}`;
1435
- const videoSourcePath = isDir ? (0, import_path11.resolve)(entryPath, videoFile) : entryPath;
1436
- const dirFiles = isDir ? (0, import_fs10.readdirSync)(entryPath) : [];
1437
- const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1438
- const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1439
- const subtitleSourcePath = subtitle ? (0, import_path11.resolve)(entryPath, subtitle) : null;
1440
- const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1441
- if (!dryRun) {
1442
- if (useHardlink) {
1443
- (0, import_fs10.mkdirSync)(destFolder, { recursive: true });
1444
- const destVideoPath = (0, import_path11.resolve)(destFolder, destVideoName);
1445
- let mode;
1446
- try {
1447
- if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1448
- (0, import_fs10.linkSync)(videoSourcePath, destVideoPath);
1449
- mode = "hardlink";
1450
- } catch {
1451
- spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1452
- (0, import_fs10.cpSync)(videoSourcePath, destVideoPath);
1453
- mode = "copy";
1454
- }
1455
- if (subtitleSourcePath && destSubtitleName) (0, import_fs10.cpSync)(subtitleSourcePath, (0, import_path11.resolve)(destFolder, destSubtitleName));
1456
- recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1457
- } else {
1458
- if (isDir) {
1459
- const keep = new Set([videoFile, subtitle].filter(Boolean));
1460
- for (const f of dirFiles.filter((f2) => !keep.has(f2))) (0, import_fs10.rmSync)((0, import_path11.resolve)(entryPath, f), { recursive: true, force: true });
1461
- (0, import_fs10.renameSync)(videoSourcePath, (0, import_path11.resolve)(entryPath, destVideoName));
1462
- if (subtitleSourcePath && destSubtitleName) (0, import_fs10.renameSync)(subtitleSourcePath, (0, import_path11.resolve)(entryPath, destSubtitleName));
1463
- moveFolder(entryPath, destFolder);
1464
- } else {
1465
- (0, import_fs10.mkdirSync)(destFolder, { recursive: true });
1466
- const destVideoPath = (0, import_path11.resolve)(destFolder, destVideoName);
1467
- if (sameDev(videoSourcePath, destRoot)) {
1468
- (0, import_fs10.renameSync)(videoSourcePath, destVideoPath);
1469
- } else {
1470
- (0, import_fs10.cpSync)(videoSourcePath, destVideoPath);
1471
- (0, import_fs10.rmSync)(videoSourcePath);
1472
- }
1473
- }
1474
- recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1475
- }
1476
1633
  }
1477
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1478
- imported++;
1479
1634
  }
1480
1635
  }
1481
1636
  spinner_default.succeed(`imported ${imported} items`);
@@ -1485,8 +1640,8 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1485
1640
  var scan_default = scan;
1486
1641
 
1487
1642
  // src/actions/undo.ts
1488
- var import_cosmetic10 = __toESM(require("cosmetic"));
1489
1643
  var import_fs11 = require("fs");
1644
+ var import_termkit11 = require("termkit");
1490
1645
  var undo = async () => {
1491
1646
  spinner_default.start();
1492
1647
  const records = getLastSession();
@@ -1498,7 +1653,7 @@ var undo = async () => {
1498
1653
  let undone = 0;
1499
1654
  for (const record of records) {
1500
1655
  (0, import_fs11.renameSync)(record.newPath, record.oldPath);
1501
- spinner_default.succeed(`${import_cosmetic10.default.cyan.encoder(record.newPath)} \u2192 ${import_cosmetic10.default.blue.encoder(record.oldPath)}`);
1656
+ spinner_default.succeed(`${import_termkit11.Color.green.encoder(record.newPath)} \u2192 ${import_termkit11.Color.white.encoder(record.oldPath)}`);
1502
1657
  undone++;
1503
1658
  }
1504
1659
  deleteSession(records[0].sessionId);
@@ -1509,9 +1664,9 @@ var undo_default = undo;
1509
1664
 
1510
1665
  // src/actions/watch.ts
1511
1666
  var import_chokidar = __toESM(require("chokidar"));
1512
- var import_cosmetic11 = __toESM(require("cosmetic"));
1513
1667
  var import_fs12 = require("fs");
1514
1668
  var import_path12 = require("path");
1669
+ var import_termkit12 = require("termkit");
1515
1670
  var sameDev2 = (a, b) => {
1516
1671
  try {
1517
1672
  let bExisting = b;
@@ -1533,6 +1688,10 @@ var findVideo2 = (dir) => (0, import_fs12.readdirSync)(dir).find((f) => {
1533
1688
  const ext = f.match(/([^.]+$)/)?.[0];
1534
1689
  return ext && videoExtensions_default.includes(ext);
1535
1690
  }) ?? null;
1691
+ var containsBook2 = (dir) => (0, import_fs12.readdirSync)(dir).some((f) => {
1692
+ const ext = f.match(/([^.]+$)/)?.[0];
1693
+ return ext && bookExtensions_default.includes(ext);
1694
+ });
1536
1695
  var findSeasonFolder2 = (showPath, season) => {
1537
1696
  if (!(0, import_fs12.existsSync)(showPath)) return null;
1538
1697
  const folders = (0, import_fs12.readdirSync)(showPath).filter((f) => {
@@ -1556,9 +1715,13 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1556
1715
  const isDir = (0, import_fs12.lstatSync)(entryPath).isDirectory();
1557
1716
  const ext = entry.match(/([^.]+$)/)?.[0];
1558
1717
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1559
- if (!isDir && !isVideo) return;
1718
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1719
+ const isBookDir = isDir && containsBook2(entryPath);
1720
+ if (!isDir && !isVideo && !isBook) return;
1560
1721
  let detectedType;
1561
- if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1722
+ if (isBook || isBookDir) {
1723
+ detectedType = "book";
1724
+ } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1562
1725
  detectedType = "ps3";
1563
1726
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1564
1727
  detectedType = "tv";
@@ -1582,7 +1745,28 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1582
1745
  }
1583
1746
  moveItem(entryPath, destPath);
1584
1747
  recordImport(sessionId, entryPath, destPath, "move");
1585
- spinner_default.succeed(`imported ${import_cosmetic11.default.cyan.encoder(destName)}`);
1748
+ spinner_default.succeed(`imported ${import_termkit12.Color.green.encoder(destName)}`);
1749
+ return;
1750
+ }
1751
+ if (detectedType === "book") {
1752
+ const destPath = (0, import_path12.resolve)(destRoot, entry);
1753
+ if ((0, import_fs12.existsSync)(destPath)) {
1754
+ spinner_default.warn(`already exists: ${entry}`);
1755
+ return;
1756
+ }
1757
+ if (isDir || isBookDir) {
1758
+ moveItem(entryPath, destPath);
1759
+ } else {
1760
+ (0, import_fs12.mkdirSync)(destRoot, { recursive: true });
1761
+ if (sameDev2(entryPath, destRoot)) {
1762
+ (0, import_fs12.renameSync)(entryPath, destPath);
1763
+ } else {
1764
+ (0, import_fs12.cpSync)(entryPath, destPath);
1765
+ (0, import_fs12.rmSync)(entryPath);
1766
+ }
1767
+ }
1768
+ recordImport(sessionId, entryPath, destPath, "move");
1769
+ spinner_default.succeed(`imported ${import_termkit12.Color.green.encoder(entry)}`);
1586
1770
  return;
1587
1771
  }
1588
1772
  const parsed = parseDownloadName(entry);
@@ -1655,7 +1839,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1655
1839
  if (isDir) (0, import_fs12.rmSync)(entryPath, { recursive: true, force: true });
1656
1840
  }
1657
1841
  recordImport(sessionId, entryPath, seasonPath, mode);
1658
- spinner_default.succeed(`imported ${import_cosmetic11.default.cyan.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1842
+ spinner_default.succeed(`imported ${import_termkit12.Color.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1659
1843
  return;
1660
1844
  }
1661
1845
  const edition = detectEdition(entry);
@@ -1712,7 +1896,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1712
1896
  }
1713
1897
  recordImport(sessionId, entryPath, destFolder, "move");
1714
1898
  }
1715
- spinner_default.succeed(`imported ${import_cosmetic11.default.cyan.encoder(folderName)}`);
1899
+ spinner_default.succeed(`imported ${import_termkit12.Color.green.encoder(folderName)}`);
1716
1900
  };
1717
1901
  var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1718
1902
  const config = getConfig();
@@ -1743,7 +1927,7 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1743
1927
  watcher.on("add", handle);
1744
1928
  spinner_default.start();
1745
1929
  spinner_default.succeed(`watching ${config.sources.length} source${config.sources.length !== 1 ? "s" : ""}`);
1746
- for (const s of config.sources) spinner_default.info(` ${import_cosmetic11.default.blue.encoder(s)}`);
1930
+ for (const s of config.sources) spinner_default.info(` ${import_termkit12.Color.white.encoder(s)}`);
1747
1931
  spinner_default.stop();
1748
1932
  process.stdin.resume();
1749
1933
  };
@@ -1754,12 +1938,12 @@ var watch_default = watch;
1754
1938
  DEFAULT_MOVIE_FORMAT,
1755
1939
  DEFAULT_SEASON_FORMAT,
1756
1940
  clean,
1757
- configAdd,
1758
- configRemove,
1759
1941
  configSet,
1760
1942
  configShow,
1761
1943
  deleteImport,
1762
1944
  deleteSession,
1945
+ destAdd,
1946
+ destRemove,
1763
1947
  detectEdition,
1764
1948
  differences,
1765
1949
  formatEpisode,
@@ -1784,6 +1968,8 @@ var watch_default = watch;
1784
1968
  reset,
1785
1969
  saveConfig,
1786
1970
  scan,
1971
+ sourceAdd,
1972
+ sourceRemove,
1787
1973
  titleCase,
1788
1974
  undo,
1789
1975
  upsertMediaInfo,