reelsort 0.2.1 → 0.2.3

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
@@ -403,6 +403,7 @@ var clean = async ({ dryRun, olderThan }) => {
403
403
  var clean_default = clean;
404
404
 
405
405
  // src/actions/config.ts
406
+ var import_fs5 = require("fs");
406
407
  var import_path4 = require("path");
407
408
  var import_termkit4 = require("termkit");
408
409
 
@@ -424,6 +425,24 @@ var formatEpisode = (season, episode, format = DEFAULT_EPISODE_FORMAT, double =
424
425
 
425
426
  // src/actions/config.ts
426
427
  var DEST_TYPES = ["movie", "tv", "ps3", "book"];
428
+ var getSourceEntries = (sources) => {
429
+ const entries = [];
430
+ for (const source of sources) {
431
+ if (!(0, import_fs5.existsSync)(source)) continue;
432
+ try {
433
+ for (const name of (0, import_fs5.readdirSync)(source)) {
434
+ if (name.startsWith(".")) continue;
435
+ try {
436
+ (0, import_fs5.lstatSync)((0, import_path4.resolve)(source, name));
437
+ entries.push(name);
438
+ } catch {
439
+ }
440
+ }
441
+ } catch {
442
+ }
443
+ }
444
+ return [...new Set(entries)].sort();
445
+ };
427
446
  var sourceAdd = async ({ dir }) => {
428
447
  const resolved = (0, import_path4.resolve)(dir);
429
448
  const config = getConfig();
@@ -440,8 +459,20 @@ var sourceAdd = async ({ dir }) => {
440
459
  spinner_default.stop();
441
460
  };
442
461
  var sourceRemove = async ({ dir }) => {
443
- const resolved = (0, import_path4.resolve)(dir);
444
462
  const config = getConfig();
463
+ if (!dir) {
464
+ if (config.sources.length === 0) {
465
+ spinner_default.start();
466
+ spinner_default.warn("no sources configured");
467
+ spinner_default.stop();
468
+ return;
469
+ }
470
+ const select = new import_termkit4.Select();
471
+ const picked = await select.ask("Which source do you want to remove?", config.sources.map((s) => ({ label: s, value: s })));
472
+ if (!picked) return;
473
+ dir = picked.value;
474
+ }
475
+ const resolved = (0, import_path4.resolve)(dir);
445
476
  const index = config.sources.indexOf(resolved);
446
477
  if (index === -1) {
447
478
  spinner_default.start();
@@ -468,10 +499,23 @@ var destAdd = async ({ type, dir }) => {
468
499
  spinner_default.stop();
469
500
  };
470
501
  var destRemove = async ({ type }) => {
502
+ const config = getConfig();
503
+ if (!type) {
504
+ const configured = DEST_TYPES.filter((t) => config.dest[t]);
505
+ if (configured.length === 0) {
506
+ spinner_default.start();
507
+ spinner_default.warn("no destinations configured");
508
+ spinner_default.stop();
509
+ return;
510
+ }
511
+ const select = new import_termkit4.Select();
512
+ const picked = await select.ask("Which destination do you want to remove?", configured.map((t) => ({ label: `${t.padEnd(6)} ${config.dest[t]}`, value: t })));
513
+ if (!picked) return;
514
+ type = picked.value;
515
+ }
471
516
  if (!DEST_TYPES.includes(type)) {
472
517
  throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
473
518
  }
474
- const config = getConfig();
475
519
  if (!config.dest[type]) {
476
520
  spinner_default.start();
477
521
  spinner_default.warn(`no ${type} destination configured`);
@@ -484,6 +528,77 @@ var destRemove = async ({ type }) => {
484
528
  spinner_default.succeed(`removed ${type} destination`);
485
529
  spinner_default.stop();
486
530
  };
531
+ var ignore = async ({ name }) => {
532
+ const config = getConfig();
533
+ config.ignore = config.ignore ?? [];
534
+ let names = name ?? [];
535
+ if (names.length === 0) {
536
+ const entries = getSourceEntries(config.sources);
537
+ if (entries.length === 0) {
538
+ spinner_default.start();
539
+ spinner_default.warn("no files found in configured sources");
540
+ spinner_default.stop();
541
+ return;
542
+ }
543
+ const ms = new import_termkit4.MultiSelect({ allowSkip: true, search: true, maxHeight: 20 });
544
+ const items = entries.map((e) => ({ label: e }));
545
+ const picked = await ms.ask("Select files to ignore during scan:", items);
546
+ if (!picked || picked.length === 0) return;
547
+ names = picked.map((p) => p.label);
548
+ }
549
+ const added = [];
550
+ for (const n of names) {
551
+ if (!config.ignore.includes(n)) {
552
+ config.ignore.push(n);
553
+ added.push(n);
554
+ }
555
+ }
556
+ if (added.length === 0) {
557
+ spinner_default.start();
558
+ spinner_default.info("all selected files are already ignored");
559
+ spinner_default.stop();
560
+ return;
561
+ }
562
+ saveConfig(config);
563
+ spinner_default.start();
564
+ for (const n of added) spinner_default.succeed(`ignoring: ${import_termkit4.Color.white.encoder(n)}`);
565
+ spinner_default.stop();
566
+ };
567
+ var ignoreRemove = async ({ name }) => {
568
+ const config = getConfig();
569
+ const list2 = config.ignore ?? [];
570
+ if (!name) {
571
+ if (list2.length === 0) {
572
+ spinner_default.start();
573
+ spinner_default.warn("ignore list is empty");
574
+ spinner_default.stop();
575
+ return;
576
+ }
577
+ const ms = new import_termkit4.MultiSelect({ allowSkip: true, search: true, maxHeight: 20 });
578
+ const items = list2.map((n) => ({ label: n }));
579
+ const picked = await ms.ask("Select files to remove from ignore list:", items);
580
+ if (!picked || picked.length === 0) return;
581
+ const toRemove = new Set(picked.map((p) => p.label));
582
+ config.ignore = list2.filter((n) => !toRemove.has(n));
583
+ saveConfig(config);
584
+ spinner_default.start();
585
+ for (const n of [...toRemove]) spinner_default.succeed(`removed from ignore list: ${import_termkit4.Color.white.encoder(n)}`);
586
+ spinner_default.stop();
587
+ return;
588
+ }
589
+ const index = list2.indexOf(name);
590
+ if (index === -1) {
591
+ spinner_default.start();
592
+ spinner_default.warn(`not in ignore list: ${import_termkit4.Color.white.encoder(name)}`);
593
+ spinner_default.stop();
594
+ return;
595
+ }
596
+ config.ignore.splice(index, 1);
597
+ saveConfig(config);
598
+ spinner_default.start();
599
+ spinner_default.succeed(`removed from ignore list: ${import_termkit4.Color.white.encoder(name)}`);
600
+ spinner_default.stop();
601
+ };
487
602
  var configSet = async ({ key, subkey, value }) => {
488
603
  const config = getConfig();
489
604
  if (key === "language") {
@@ -549,24 +664,30 @@ Subtitle language: ${import_termkit4.Color.green.encoder(config.language ?? "eng
549
664
  console.log(`Movie format: ${import_termkit4.Color.green.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
550
665
  console.log(`Episode format: ${import_termkit4.Color.green.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
551
666
  console.log(`Season folder: ${import_termkit4.Color.green.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
667
+ console.log("\nIgnored files:");
668
+ if (!config.ignore || config.ignore.length === 0) {
669
+ console.log(" (none)");
670
+ } else {
671
+ for (const name of config.ignore) console.log(` ${import_termkit4.Color.white.encoder(name)}`);
672
+ }
552
673
  console.log();
553
674
  };
554
675
 
555
676
  // src/actions/differences.ts
556
- var import_fs5 = require("fs");
677
+ var import_fs6 = require("fs");
557
678
  var import_path5 = require("path");
558
679
  var import_termkit5 = require("termkit");
559
- var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
680
+ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore: ignore2 }) => {
560
681
  let dir1 = rawDir1;
561
682
  let dir2 = rawDir2;
562
683
  spinner_default.text = `checking differences between ${import_termkit5.Color.white.encoder(dir1)} and ${import_termkit5.Color.white.encoder(dir2)}`;
563
684
  spinner_default.start();
564
685
  dir1 = (0, import_path5.resolve)(dir1);
565
686
  dir2 = (0, import_path5.resolve)(dir2);
566
- if (!(0, import_fs5.existsSync)(dir1)) throw new Error(`dir1 ${dir1} does not exist`);
567
- if (!(0, import_fs5.existsSync)(dir2)) throw new Error(`dir2 ${dir2} does not exist`);
568
- let list1 = (0, import_fs5.readdirSync)(dir1);
569
- let list2 = (0, import_fs5.readdirSync)(dir2);
687
+ if (!(0, import_fs6.existsSync)(dir1)) throw new Error(`dir1 ${dir1} does not exist`);
688
+ if (!(0, import_fs6.existsSync)(dir2)) throw new Error(`dir2 ${dir2} does not exist`);
689
+ let list1 = (0, import_fs6.readdirSync)(dir1);
690
+ let list2 = (0, import_fs6.readdirSync)(dir2);
570
691
  if (only && only.length) {
571
692
  list1 = list1.filter((i) => {
572
693
  for (const o of only) if (i.endsWith(o)) return true;
@@ -577,13 +698,13 @@ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
577
698
  return false;
578
699
  });
579
700
  }
580
- if (ignore && ignore.length) {
701
+ if (ignore2 && ignore2.length) {
581
702
  list1 = list1.filter((i) => {
582
- for (const o of ignore) if (i.endsWith(o)) return false;
703
+ for (const o of ignore2) if (i.endsWith(o)) return false;
583
704
  return true;
584
705
  });
585
706
  list2 = list2.filter((i) => {
586
- for (const o of ignore) if (i.endsWith(o)) return false;
707
+ for (const o of ignore2) if (i.endsWith(o)) return false;
587
708
  return true;
588
709
  });
589
710
  }
@@ -680,16 +801,16 @@ ${import_termkit7.Color.yellow.encoder(label)} (${folders.length} item${folders
680
801
  var history_default = history;
681
802
 
682
803
  // src/actions/link.ts
683
- var import_fs6 = require("fs");
804
+ var import_fs7 = require("fs");
684
805
  var import_path7 = require("path");
685
806
  var import_termkit8 = require("termkit");
686
807
  var parseShowTitle = (folderName) => {
687
808
  const withoutYear = folderName.replace(/\s*\(\d{4}\)\s*$/, "").trim();
688
809
  return withoutYear || folderName;
689
810
  };
690
- var subdirs = (dir) => (0, import_fs6.readdirSync)(dir).filter((f) => {
811
+ var subdirs = (dir) => (0, import_fs7.readdirSync)(dir).filter((f) => {
691
812
  try {
692
- return (0, import_fs6.lstatSync)((0, import_path7.resolve)(dir, f)).isDirectory();
813
+ return (0, import_fs7.lstatSync)((0, import_path7.resolve)(dir, f)).isDirectory();
693
814
  } catch {
694
815
  return false;
695
816
  }
@@ -699,7 +820,7 @@ var link = async ({ force }) => {
699
820
  if (!config.tmdbApiKey) throw new Error("TMDb API key required \u2014 run: reelsort config set tmdb-key <key>");
700
821
  const destRoot = config.dest.tv;
701
822
  if (!destRoot) throw new Error("no TV destination configured \u2014 run: reelsort config set dest tv <dir>");
702
- if (!(0, import_fs6.existsSync)(destRoot)) throw new Error(`TV destination not found: ${destRoot}`);
823
+ if (!(0, import_fs7.existsSync)(destRoot)) throw new Error(`TV destination not found: ${destRoot}`);
703
824
  const shows2 = subdirs(destRoot);
704
825
  let linked = 0, skipped = 0, notFound = 0;
705
826
  for (const show of shows2) {
@@ -749,23 +870,23 @@ var link = async ({ force }) => {
749
870
  var link_default = link;
750
871
 
751
872
  // src/actions/list.ts
752
- var import_fs8 = require("fs");
873
+ var import_fs9 = require("fs");
753
874
  var import_path9 = require("path");
754
875
  var import_termkit9 = require("termkit");
755
876
 
756
877
  // src/helpers/dirSize.ts
757
- var import_fs7 = require("fs");
878
+ var import_fs8 = require("fs");
758
879
  var import_path8 = require("path");
759
880
  var dirSize = (dir) => {
760
881
  let total = 0;
761
882
  try {
762
- for (const entry of (0, import_fs7.readdirSync)(dir, { withFileTypes: true })) {
883
+ for (const entry of (0, import_fs8.readdirSync)(dir, { withFileTypes: true })) {
763
884
  const full = (0, import_path8.resolve)(dir, entry.name);
764
885
  if (entry.isDirectory()) {
765
886
  total += dirSize(full);
766
887
  } else if (entry.isFile()) {
767
888
  try {
768
- total += (0, import_fs7.statSync)(full).size;
889
+ total += (0, import_fs8.statSync)(full).size;
769
890
  } catch {
770
891
  }
771
892
  }
@@ -860,7 +981,7 @@ var videoExtensions_default = [
860
981
  var DEST_TYPES2 = ["movie", "tv", "ps3"];
861
982
  var findVideoFile = (dir) => {
862
983
  try {
863
- return (0, import_fs8.readdirSync)(dir).find((f) => {
984
+ return (0, import_fs9.readdirSync)(dir).find((f) => {
864
985
  const ext = f.match(/([^.]+$)/)?.[0]?.toLowerCase();
865
986
  return ext && videoExtensions_default.includes(ext);
866
987
  }) ?? null;
@@ -870,7 +991,7 @@ var findVideoFile = (dir) => {
870
991
  };
871
992
  var hasSubtitle = (dir) => {
872
993
  try {
873
- return (0, import_fs8.readdirSync)(dir).some((f) => {
994
+ return (0, import_fs9.readdirSync)(dir).some((f) => {
874
995
  const ext = f.match(/([^.]+$)/)?.[0]?.toLowerCase();
875
996
  return ext && subtitleExtensions_default.includes(ext);
876
997
  });
@@ -889,14 +1010,14 @@ var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter
889
1010
  if (types.length === 0) throw new Error("no destinations configured \u2014 run: reelsort config set dest movie <dir>");
890
1011
  for (const t of types) {
891
1012
  const destRoot = config.dest[t];
892
- if (!(0, import_fs8.existsSync)(destRoot)) {
1013
+ if (!(0, import_fs9.existsSync)(destRoot)) {
893
1014
  console.log(`
894
1015
  ${t.toUpperCase()} ${import_termkit9.Color.white.encoder(destRoot)} (not found)`);
895
1016
  continue;
896
1017
  }
897
- const folders = (0, import_fs8.readdirSync)(destRoot).filter((f) => {
1018
+ const folders = (0, import_fs9.readdirSync)(destRoot).filter((f) => {
898
1019
  try {
899
- return (0, import_fs8.lstatSync)((0, import_path9.resolve)(destRoot, f)).isDirectory();
1020
+ return (0, import_fs9.lstatSync)((0, import_path9.resolve)(destRoot, f)).isDirectory();
900
1021
  } catch {
901
1022
  return false;
902
1023
  }
@@ -951,7 +1072,7 @@ ${import_termkit9.Color.yellow.encoder(t.toUpperCase())} ${import_termkit9.Colo
951
1072
  var list_default = list;
952
1073
 
953
1074
  // src/actions/missing.ts
954
- var import_fs9 = require("fs");
1075
+ var import_fs10 = require("fs");
955
1076
  var import_path10 = require("path");
956
1077
  var import_termkit10 = require("termkit");
957
1078
  var TODAY = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -961,7 +1082,7 @@ var parseSeasonNumber = (folderName) => {
961
1082
  };
962
1083
  var parseEpisodeNumbers = (dir) => {
963
1084
  const episodes = /* @__PURE__ */ new Set();
964
- for (const file of (0, import_fs9.readdirSync)(dir)) {
1085
+ for (const file of (0, import_fs10.readdirSync)(dir)) {
965
1086
  const ext = file.match(/([^.]+$)/)?.[0]?.toLowerCase();
966
1087
  if (!ext || !videoExtensions_default.includes(ext)) continue;
967
1088
  const m = file.match(/(?:S\d+E|(?:\d+)x)(\d+)/i);
@@ -969,9 +1090,9 @@ var parseEpisodeNumbers = (dir) => {
969
1090
  }
970
1091
  return episodes;
971
1092
  };
972
- var subdirs2 = (dir) => (0, import_fs9.readdirSync)(dir).filter((f) => {
1093
+ var subdirs2 = (dir) => (0, import_fs10.readdirSync)(dir).filter((f) => {
973
1094
  try {
974
- return (0, import_fs9.lstatSync)((0, import_path10.resolve)(dir, f)).isDirectory();
1095
+ return (0, import_fs10.lstatSync)((0, import_path10.resolve)(dir, f)).isDirectory();
975
1096
  } catch {
976
1097
  return false;
977
1098
  }
@@ -981,7 +1102,7 @@ var missing = async ({ show: showFilter }) => {
981
1102
  if (!config.tmdbApiKey) throw new Error("TMDb API key required \u2014 run: reelsort config set tmdb-key <key>");
982
1103
  const destRoot = config.dest.tv;
983
1104
  if (!destRoot) throw new Error("no TV destination configured \u2014 run: reelsort config set dest tv <dir>");
984
- if (!(0, import_fs9.existsSync)(destRoot)) throw new Error(`TV destination not found: ${destRoot}`);
1105
+ if (!(0, import_fs10.existsSync)(destRoot)) throw new Error(`TV destination not found: ${destRoot}`);
985
1106
  const filter = showFilter?.toLowerCase();
986
1107
  const allShows = getShows();
987
1108
  const endedPaths = new Set(allShows.filter((s) => s.ended).map((s) => s.path));
@@ -1034,9 +1155,18 @@ var missing_default = missing;
1034
1155
 
1035
1156
  // src/actions/probe.ts
1036
1157
  var import_child_process = require("child_process");
1037
- var import_fs10 = require("fs");
1158
+ var import_fs11 = require("fs");
1038
1159
  var import_path11 = require("path");
1039
1160
  var import_termkit11 = require("termkit");
1161
+
1162
+ // src/refs/verbose.ts
1163
+ var _verbose = false;
1164
+ var setVerbose = (v) => {
1165
+ _verbose = v;
1166
+ };
1167
+ var isVerbose = () => _verbose;
1168
+
1169
+ // src/actions/probe.ts
1040
1170
  var DEST_TYPES3 = ["movie", "tv", "ps3"];
1041
1171
  var CODEC_MAP2 = {
1042
1172
  hevc: "x265",
@@ -1082,12 +1212,12 @@ var runFfprobe = (filePath) => {
1082
1212
  }
1083
1213
  };
1084
1214
  var walkVideoFiles = (dir, depth = 0, maxDepth = 3) => {
1085
- if (!(0, import_fs10.existsSync)(dir) || depth > maxDepth) return [];
1215
+ if (!(0, import_fs11.existsSync)(dir) || depth > maxDepth) return [];
1086
1216
  const results = [];
1087
- for (const entry of (0, import_fs10.readdirSync)(dir)) {
1217
+ for (const entry of (0, import_fs11.readdirSync)(dir)) {
1088
1218
  const entryPath = (0, import_path11.resolve)(dir, entry);
1089
1219
  try {
1090
- if ((0, import_fs10.lstatSync)(entryPath).isDirectory()) {
1220
+ if ((0, import_fs11.lstatSync)(entryPath).isDirectory()) {
1091
1221
  results.push(...walkVideoFiles(entryPath, depth + 1, maxDepth));
1092
1222
  } else {
1093
1223
  const ext = entry.match(/([^.]+$)/)?.[0]?.toLowerCase();
@@ -1098,7 +1228,7 @@ var walkVideoFiles = (dir, depth = 0, maxDepth = 3) => {
1098
1228
  }
1099
1229
  return results;
1100
1230
  };
1101
- var probe = async ({ type, force, verbose }) => {
1231
+ var probe = async ({ type, force }) => {
1102
1232
  spinner_default.start();
1103
1233
  if (!isFfprobeAvailable()) {
1104
1234
  spinner_default.fail("ffprobe not found \u2014 install ffmpeg to use this command");
@@ -1111,24 +1241,24 @@ var probe = async ({ type, force, verbose }) => {
1111
1241
  let probed = 0, skipped = 0, failed = 0;
1112
1242
  for (const t of types) {
1113
1243
  const destRoot = config.dest[t];
1114
- if (!(0, import_fs10.existsSync)(destRoot)) continue;
1244
+ if (!(0, import_fs11.existsSync)(destRoot)) continue;
1115
1245
  spinner_default.text = `scanning ${import_termkit11.Color.white.encoder(destRoot)}`;
1116
1246
  const files = walkVideoFiles(destRoot);
1117
1247
  for (const filePath of files) {
1118
1248
  if (!force && getMediaInfo(filePath)) {
1119
- if (verbose) spinner_default.info(`already probed: ${filePath}`);
1249
+ if (isVerbose()) spinner_default.info(`already probed: ${filePath}`);
1120
1250
  skipped++;
1121
1251
  continue;
1122
1252
  }
1123
1253
  spinner_default.text = `probing ${import_termkit11.Color.white.encoder(filePath)}`;
1124
1254
  const result = runFfprobe(filePath);
1125
1255
  if (!result) {
1126
- if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
1256
+ if (isVerbose()) spinner_default.warn(`ffprobe failed: ${filePath}`);
1127
1257
  failed++;
1128
1258
  continue;
1129
1259
  }
1130
1260
  upsertMediaInfo(filePath, result.codec, result.resolution, result.width, result.height, result.duration);
1131
- if (verbose) spinner_default.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
1261
+ if (isVerbose()) spinner_default.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
1132
1262
  probed++;
1133
1263
  }
1134
1264
  }
@@ -1140,7 +1270,7 @@ var probe = async ({ type, force, verbose }) => {
1140
1270
  var probe_default = probe;
1141
1271
 
1142
1272
  // src/actions/rename.ts
1143
- var import_fs11 = require("fs");
1273
+ var import_fs12 = require("fs");
1144
1274
  var import_path12 = require("path");
1145
1275
  var import_rimraf = require("rimraf");
1146
1276
  var import_termkit12 = require("termkit");
@@ -1198,7 +1328,7 @@ var titleCase_default = (s) => {
1198
1328
  };
1199
1329
 
1200
1330
  // src/actions/rename.ts
1201
- var rename = async ({ dir: inputDir, type, verbose }) => {
1331
+ var rename = async ({ dir: inputDir, type }) => {
1202
1332
  const dir = (0, import_path12.resolve)(inputDir);
1203
1333
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1204
1334
  const config = getConfig();
@@ -1206,13 +1336,13 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1206
1336
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1207
1337
  spinner_default.text = `renaming in ${import_termkit12.Color.white.encoder(dir)}`;
1208
1338
  spinner_default.start();
1209
- if (!(0, import_fs11.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
1210
- const list2 = (0, import_fs11.readdirSync)(dir);
1339
+ if (!(0, import_fs12.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
1340
+ const list2 = (0, import_fs12.readdirSync)(dir);
1211
1341
  let renamed = 0, removed = 0, skipped = 0;
1212
1342
  for (const [index, entry] of list2.entries()) {
1213
1343
  spinner_default.text = `renaming in ${import_termkit12.Color.white.encoder(dir)} ${index + 1}/${list2.length}`;
1214
- if (!(0, import_fs11.lstatSync)((0, import_path12.resolve)(dir, entry)).isDirectory()) {
1215
- if (verbose) spinner_default.info(`skipped ${entry}`);
1344
+ if (!(0, import_fs12.lstatSync)((0, import_path12.resolve)(dir, entry)).isDirectory()) {
1345
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1216
1346
  skipped++;
1217
1347
  continue;
1218
1348
  }
@@ -1222,13 +1352,13 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1222
1352
  const nameMatch = entry.match(/(?<=\[).+?(?=\])/);
1223
1353
  const id = entry.split("-")[0];
1224
1354
  if (!nameMatch || !id) {
1225
- if (verbose) spinner_default.info(`skipped ${entry}`);
1355
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1226
1356
  skipped++;
1227
1357
  continue;
1228
1358
  }
1229
1359
  const ps3Old = (0, import_path12.resolve)(dir, entry);
1230
1360
  const ps3New = (0, import_path12.resolve)(dir, `${nameMatch[0]} [${id}]`);
1231
- (0, import_fs11.renameSync)(ps3Old, ps3New);
1361
+ (0, import_fs12.renameSync)(ps3Old, ps3New);
1232
1362
  recordRename(sessionId, ps3Old, ps3New);
1233
1363
  spinner_default.succeed(`${nameMatch[0]} [${id}]`);
1234
1364
  renamed++;
@@ -1236,37 +1366,37 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1236
1366
  }
1237
1367
  const yearMatch = entry.match(/\([^\d]*(\d+)[^\d]*\)/);
1238
1368
  if (!yearMatch) {
1239
- if (verbose) spinner_default.info(`skipped ${entry}`);
1369
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1240
1370
  skipped++;
1241
1371
  continue;
1242
1372
  }
1243
1373
  const year = yearMatch[0];
1244
1374
  if (year.length !== 6) {
1245
- if (verbose) spinner_default.info(`skipped ${entry}`);
1375
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1246
1376
  skipped++;
1247
1377
  continue;
1248
1378
  }
1249
1379
  const title = titleCase_default(entry.substring(0, entry.indexOf(year)).trim());
1250
- const sublist = (0, import_fs11.readdirSync)((0, import_path12.resolve)(dir, entry));
1380
+ const sublist = (0, import_fs12.readdirSync)((0, import_path12.resolve)(dir, entry));
1251
1381
  const video = sublist.find((f) => {
1252
1382
  const ext2 = f.match(/([^.]+$)/)?.[0];
1253
1383
  return videoExtensions_default.includes(ext2) && title.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
1254
1384
  });
1255
1385
  if (!video) {
1256
- if (verbose) spinner_default.info(`skipped ${entry}`);
1386
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1257
1387
  skipped++;
1258
1388
  continue;
1259
1389
  }
1260
1390
  const ext = video.match(/([^.]+$)/)?.[0];
1261
1391
  if (!ext) {
1262
- if (verbose) spinner_default.info(`skipped ${entry}`);
1392
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1263
1393
  skipped++;
1264
1394
  continue;
1265
1395
  }
1266
1396
  const yearNum = parseInt(year.replace(/\D/g, ""));
1267
1397
  const formatted = formatMovieName(movieFormat, title, yearNum);
1268
1398
  if (entry === formatted && video === `${formatted}.${ext}`) {
1269
- if (verbose) spinner_default.info(`skipped ${entry}`);
1399
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1270
1400
  skipped++;
1271
1401
  continue;
1272
1402
  }
@@ -1282,11 +1412,11 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1282
1412
  const fileNew = (0, import_path12.resolve)(dir, entry, `${formatted}.${ext}`);
1283
1413
  const folderOld = (0, import_path12.resolve)(dir, entry);
1284
1414
  const folderNew = (0, import_path12.resolve)(dir, formatted);
1285
- (0, import_fs11.renameSync)(fileOld, fileNew);
1415
+ (0, import_fs12.renameSync)(fileOld, fileNew);
1286
1416
  if (subtitle && subtitleExt) {
1287
- (0, import_fs11.renameSync)((0, import_path12.resolve)(dir, entry, subtitle), (0, import_path12.resolve)(dir, entry, `${formatted}.${subtitleExt}`));
1417
+ (0, import_fs12.renameSync)((0, import_path12.resolve)(dir, entry, subtitle), (0, import_path12.resolve)(dir, entry, `${formatted}.${subtitleExt}`));
1288
1418
  }
1289
- (0, import_fs11.renameSync)(folderOld, folderNew);
1419
+ (0, import_fs12.renameSync)(folderOld, folderNew);
1290
1420
  recordRename(sessionId, fileOld, fileNew);
1291
1421
  recordRename(sessionId, folderOld, folderNew);
1292
1422
  spinner_default.succeed(formatted);
@@ -1301,7 +1431,7 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
1301
1431
  var rename_default = rename;
1302
1432
 
1303
1433
  // src/actions/reset.ts
1304
- var import_fs12 = require("fs");
1434
+ var import_fs13 = require("fs");
1305
1435
  var import_path13 = require("path");
1306
1436
  var import_termkit13 = require("termkit");
1307
1437
  var reset = async ({ dir: inputDir, double }) => {
@@ -1309,8 +1439,8 @@ var reset = async ({ dir: inputDir, double }) => {
1309
1439
  spinner_default.text = `resetting episodes in ${import_termkit13.Color.white.encoder(dir)}`;
1310
1440
  spinner_default.start();
1311
1441
  dir = (0, import_path13.resolve)(dir);
1312
- if (!(0, import_fs12.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
1313
- const list2 = (0, import_fs12.readdirSync)(dir).sort();
1442
+ if (!(0, import_fs13.existsSync)(dir)) throw new Error(`dir ${dir} does not exist`);
1443
+ const list2 = (0, import_fs13.readdirSync)(dir).sort();
1314
1444
  const folder = dir.replace(/\./g, " ").split(import_path13.sep).pop();
1315
1445
  let season;
1316
1446
  let sub = folder.includes("season") ? folder.substring(folder.indexOf("season") + "season".length, folder.length).trim() : /s\d/i.test(folder) ? folder.substring(folder.search(/s\d/i) + 1, folder.length).trim() : null;
@@ -1343,7 +1473,7 @@ var reset = async ({ dir: inputDir, double }) => {
1343
1473
  skipped++;
1344
1474
  continue;
1345
1475
  }
1346
- (0, import_fs12.renameSync)((0, import_path13.resolve)(dir, i), (0, import_path13.resolve)(dir, name));
1476
+ (0, import_fs13.renameSync)((0, import_path13.resolve)(dir, i), (0, import_path13.resolve)(dir, name));
1347
1477
  renamed++;
1348
1478
  }
1349
1479
  spinner_default.succeed(`renamed ${renamed} files`);
@@ -1354,7 +1484,7 @@ var reset = async ({ dir: inputDir, double }) => {
1354
1484
  var reset_default = reset;
1355
1485
 
1356
1486
  // src/actions/scan.ts
1357
- var import_fs13 = require("fs");
1487
+ var import_fs14 = require("fs");
1358
1488
  var import_path14 = require("path");
1359
1489
  var import_termkit14 = require("termkit");
1360
1490
 
@@ -1423,33 +1553,134 @@ var bookExtensions_default = ["epub", "mobi", "azw3", "azw"];
1423
1553
  var sameDev = (a, b) => {
1424
1554
  try {
1425
1555
  let bExisting = b;
1426
- while (!(0, import_fs13.existsSync)(bExisting)) bExisting = (0, import_path14.dirname)(bExisting);
1427
- return (0, import_fs13.statSync)(a).dev === (0, import_fs13.statSync)(bExisting).dev;
1556
+ while (!(0, import_fs14.existsSync)(bExisting)) bExisting = (0, import_path14.dirname)(bExisting);
1557
+ return (0, import_fs14.statSync)(a).dev === (0, import_fs14.statSync)(bExisting).dev;
1428
1558
  } catch {
1429
1559
  return false;
1430
1560
  }
1431
1561
  };
1432
1562
  var moveFolder = (src, dest) => {
1433
1563
  if (sameDev(src, dest)) {
1434
- (0, import_fs13.renameSync)(src, dest);
1564
+ (0, import_fs14.renameSync)(src, dest);
1435
1565
  } else {
1436
- (0, import_fs13.cpSync)(src, dest, { recursive: true });
1437
- (0, import_fs13.rmSync)(src, { recursive: true, force: true });
1566
+ (0, import_fs14.cpSync)(src, dest, { recursive: true });
1567
+ (0, import_fs14.rmSync)(src, { recursive: true, force: true });
1438
1568
  }
1439
1569
  };
1440
- var findVideo = (dir) => (0, import_fs13.readdirSync)(dir).find((f) => {
1570
+ var findVideo = (dir) => (0, import_fs14.readdirSync)(dir).find((f) => {
1441
1571
  const ext = f.match(/([^.]+$)/)?.[0];
1442
1572
  return ext && videoExtensions_default.includes(ext);
1443
1573
  }) ?? null;
1444
- var containsBook = (dir) => (0, import_fs13.readdirSync)(dir).some((f) => {
1574
+ var containsBook = (dir, depth = 2) => (0, import_fs14.readdirSync)(dir).some((f) => {
1445
1575
  const ext = f.match(/([^.]+$)/)?.[0];
1446
- return ext && bookExtensions_default.includes(ext);
1576
+ if (ext && bookExtensions_default.includes(ext)) return true;
1577
+ if (depth > 1) {
1578
+ try {
1579
+ const sub = (0, import_path14.resolve)(dir, f);
1580
+ if ((0, import_fs14.lstatSync)(sub).isDirectory()) return containsBook(sub, depth - 1);
1581
+ } catch {
1582
+ }
1583
+ }
1584
+ return false;
1447
1585
  });
1586
+ var isTvEpisodeName = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
1587
+ var isSeasonDirName = (name) => !isTvEpisodeName(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
1588
+ var gatherEntries = (source) => {
1589
+ const result = [];
1590
+ for (const name of (0, import_fs14.readdirSync)(source)) {
1591
+ const fullPath = (0, import_path14.resolve)(source, name);
1592
+ let isDir;
1593
+ try {
1594
+ isDir = (0, import_fs14.lstatSync)(fullPath).isDirectory();
1595
+ } catch {
1596
+ continue;
1597
+ }
1598
+ const ext = name.match(/([^.]+$)/)?.[0];
1599
+ const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1600
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1601
+ if (!isDir && !isVideo && !isBook) continue;
1602
+ if (!isDir) {
1603
+ result.push({ entry: name, entryPath: fullPath, isDir: false });
1604
+ continue;
1605
+ }
1606
+ if (/(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name) || isTvEpisodeName(name)) {
1607
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1608
+ continue;
1609
+ }
1610
+ let children;
1611
+ try {
1612
+ children = (0, import_fs14.readdirSync)(fullPath);
1613
+ } catch {
1614
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1615
+ continue;
1616
+ }
1617
+ if (children.some((c) => isTvEpisodeName(c))) {
1618
+ for (const child of children) {
1619
+ const childPath = (0, import_path14.resolve)(fullPath, child);
1620
+ let childIsDir;
1621
+ try {
1622
+ childIsDir = (0, import_fs14.lstatSync)(childPath).isDirectory();
1623
+ } catch {
1624
+ continue;
1625
+ }
1626
+ const childExt = child.match(/([^.]+$)/)?.[0];
1627
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1628
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1629
+ }
1630
+ continue;
1631
+ }
1632
+ const seasonDirs = children.filter((c) => {
1633
+ try {
1634
+ return isSeasonDirName(c) && (0, import_fs14.lstatSync)((0, import_path14.resolve)(fullPath, c)).isDirectory();
1635
+ } catch {
1636
+ return false;
1637
+ }
1638
+ });
1639
+ if (seasonDirs.length > 0) {
1640
+ for (const seasonDir of seasonDirs) {
1641
+ const seasonPath = (0, import_path14.resolve)(fullPath, seasonDir);
1642
+ let seasonChildren;
1643
+ try {
1644
+ seasonChildren = (0, import_fs14.readdirSync)(seasonPath);
1645
+ } catch {
1646
+ continue;
1647
+ }
1648
+ for (const child of seasonChildren) {
1649
+ const childPath = (0, import_path14.resolve)(seasonPath, child);
1650
+ let childIsDir;
1651
+ try {
1652
+ childIsDir = (0, import_fs14.lstatSync)(childPath).isDirectory();
1653
+ } catch {
1654
+ continue;
1655
+ }
1656
+ const childExt = child.match(/([^.]+$)/)?.[0];
1657
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1658
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1659
+ }
1660
+ }
1661
+ continue;
1662
+ }
1663
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1664
+ }
1665
+ return result;
1666
+ };
1667
+ var findShowFolder = (destRoot, title) => {
1668
+ if (!(0, import_fs14.existsSync)(destRoot)) return null;
1669
+ const normalize = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
1670
+ const target = normalize(title);
1671
+ return (0, import_fs14.readdirSync)(destRoot).filter((f) => {
1672
+ try {
1673
+ return (0, import_fs14.lstatSync)((0, import_path14.resolve)(destRoot, f)).isDirectory();
1674
+ } catch {
1675
+ return false;
1676
+ }
1677
+ }).find((f) => normalize(f) === target) ?? null;
1678
+ };
1448
1679
  var findSeasonFolder = (showPath, season) => {
1449
- if (!(0, import_fs13.existsSync)(showPath)) return null;
1450
- const folders = (0, import_fs13.readdirSync)(showPath).filter((f) => {
1680
+ if (!(0, import_fs14.existsSync)(showPath)) return null;
1681
+ const folders = (0, import_fs14.readdirSync)(showPath).filter((f) => {
1451
1682
  try {
1452
- return (0, import_fs13.lstatSync)((0, import_path14.resolve)(showPath, f)).isDirectory();
1683
+ return (0, import_fs14.lstatSync)((0, import_path14.resolve)(showPath, f)).isDirectory();
1453
1684
  } catch {
1454
1685
  return false;
1455
1686
  }
@@ -1468,7 +1699,14 @@ var classifyMovieConfidence = (entry) => {
1468
1699
  if (/\.\d{4}\..*?(?:2160p|1080p|720p|BluRay|WEBRip|WEB-DL|HDTV)/i.test(entry)) return "auto";
1469
1700
  return "ambiguous";
1470
1701
  };
1471
- var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, interactive }) => {
1702
+ var typeColor = {
1703
+ movie: import_termkit14.Color.white.cyan,
1704
+ tv: import_termkit14.Color.white.green,
1705
+ book: import_termkit14.Color.white.yellow,
1706
+ ps3: import_termkit14.Color.white.magenta
1707
+ };
1708
+ var typeTag = (t) => isVerbose() ? import_termkit14.Color.white.faint.encoder(` (${t})`) : "";
1709
+ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactive }) => {
1472
1710
  const config = getConfig();
1473
1711
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1474
1712
  const language = config.language ?? "eng";
@@ -1507,90 +1745,90 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1507
1745
  const edition = detectEdition(entry);
1508
1746
  const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1509
1747
  const destFolder = (0, import_path14.resolve)(destRoot, folderName);
1510
- if ((0, import_fs13.existsSync)(destFolder)) {
1748
+ if ((0, import_fs14.existsSync)(destFolder)) {
1511
1749
  spinner_default.warn(`already exists: ${folderName}`);
1512
1750
  return false;
1513
1751
  }
1514
1752
  const videoFile = isDir ? findVideo(entryPath) : entry;
1515
1753
  if (!videoFile) {
1516
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1754
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1517
1755
  return false;
1518
1756
  }
1519
1757
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1520
1758
  const destVideoName = `${folderName}.${videoExt}`;
1521
1759
  const videoSourcePath = isDir ? (0, import_path14.resolve)(entryPath, videoFile) : entryPath;
1522
- const dirFiles = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
1760
+ const dirFiles = isDir ? (0, import_fs14.readdirSync)(entryPath) : [];
1523
1761
  const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1524
1762
  const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1525
1763
  const subtitleSourcePath = subtitle ? (0, import_path14.resolve)(entryPath, subtitle) : null;
1526
1764
  const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1527
1765
  if (!dryRun) {
1528
1766
  if (useHardlink) {
1529
- (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1767
+ (0, import_fs14.mkdirSync)(destFolder, { recursive: true });
1530
1768
  const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1531
1769
  let mode;
1532
1770
  try {
1533
1771
  if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1534
- (0, import_fs13.linkSync)(videoSourcePath, destVideoPath);
1772
+ (0, import_fs14.linkSync)(videoSourcePath, destVideoPath);
1535
1773
  mode = "hardlink";
1536
1774
  } catch {
1537
1775
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1538
- (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1776
+ (0, import_fs14.cpSync)(videoSourcePath, destVideoPath);
1539
1777
  mode = "copy";
1540
1778
  }
1541
- if (subtitleSourcePath && destSubtitleName) (0, import_fs13.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(destFolder, destSubtitleName));
1779
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs14.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(destFolder, destSubtitleName));
1542
1780
  recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1543
1781
  } else {
1544
1782
  if (isDir) {
1545
1783
  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));
1784
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) (0, import_fs14.rmSync)((0, import_path14.resolve)(entryPath, f), { recursive: true, force: true });
1785
+ (0, import_fs14.renameSync)(videoSourcePath, (0, import_path14.resolve)(entryPath, destVideoName));
1786
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs14.renameSync)(subtitleSourcePath, (0, import_path14.resolve)(entryPath, destSubtitleName));
1549
1787
  moveFolder(entryPath, destFolder);
1550
1788
  } else {
1551
- (0, import_fs13.mkdirSync)(destFolder, { recursive: true });
1789
+ (0, import_fs14.mkdirSync)(destFolder, { recursive: true });
1552
1790
  const destVideoPath = (0, import_path14.resolve)(destFolder, destVideoName);
1553
1791
  if (sameDev(videoSourcePath, destRoot)) {
1554
- (0, import_fs13.renameSync)(videoSourcePath, destVideoPath);
1792
+ (0, import_fs14.renameSync)(videoSourcePath, destVideoPath);
1555
1793
  } else {
1556
- (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1557
- (0, import_fs13.rmSync)(videoSourcePath);
1794
+ (0, import_fs14.cpSync)(videoSourcePath, destVideoPath);
1795
+ (0, import_fs14.rmSync)(videoSourcePath);
1558
1796
  }
1559
1797
  }
1560
1798
  recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1561
1799
  }
1562
1800
  }
1563
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1801
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.movie.encoder(folderName)}${typeTag("movie")}`);
1564
1802
  return true;
1565
1803
  };
1566
1804
  spinner_default.start();
1567
1805
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1568
1806
  let imported = 0, skipped = 0;
1569
1807
  const pendingMovies = [];
1808
+ const pendingTv = [];
1809
+ const ignoreSet = new Set(config.ignore ?? []);
1810
+ const seenIgnored = /* @__PURE__ */ new Set();
1570
1811
  for (const source of config.sources) {
1571
- if (!(0, import_fs13.existsSync)(source)) {
1812
+ if (!(0, import_fs14.existsSync)(source)) {
1572
1813
  spinner_default.warn(`source not found: ${import_termkit14.Color.white.encoder(source)}`);
1573
1814
  continue;
1574
1815
  }
1575
1816
  spinner_default.text = `scanning ${import_termkit14.Color.white.encoder(source)}`;
1576
- for (const entry of (0, import_fs13.readdirSync)(source)) {
1577
- const entryPath = (0, import_path14.resolve)(source, entry);
1578
- const isDir = (0, import_fs13.lstatSync)(entryPath).isDirectory();
1817
+ for (const { entry, entryPath, isDir } of gatherEntries(source)) {
1818
+ if (ignoreSet.has(entry)) {
1819
+ seenIgnored.add(entry);
1820
+ if (isVerbose()) spinner_default.info(`ignored: ${entry}`);
1821
+ continue;
1822
+ }
1579
1823
  const ext = entry.match(/([^.]+$)/)?.[0];
1580
- const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1581
1824
  const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1582
1825
  const isBookDir = isDir && containsBook(entryPath);
1583
- if (!isDir && !isVideo && !isBook) {
1584
- if (verbose) spinner_default.info(`skipped ${entry}`);
1585
- skipped++;
1586
- continue;
1587
- }
1588
1826
  let detectedType;
1589
1827
  if (type) {
1590
1828
  detectedType = type;
1591
1829
  } else if (isBook || isBookDir) {
1592
1830
  detectedType = "book";
1593
- } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1831
+ } else if (isDir && /^[A-Z]{4}\d{5}/i.test(entry)) {
1594
1832
  detectedType = "ps3";
1595
1833
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1596
1834
  detectedType = "tv";
@@ -1599,7 +1837,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1599
1837
  }
1600
1838
  const destRoot = config.dest[detectedType];
1601
1839
  if (!destRoot) {
1602
- if (verbose) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1840
+ if (isVerbose()) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1603
1841
  skipped++;
1604
1842
  continue;
1605
1843
  }
@@ -1612,7 +1850,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1612
1850
  }
1613
1851
  const destName = `${nameMatch[0]} [${id}]`;
1614
1852
  const destPath = (0, import_path14.resolve)(destRoot, destName);
1615
- if ((0, import_fs13.existsSync)(destPath)) {
1853
+ if ((0, import_fs14.existsSync)(destPath)) {
1616
1854
  spinner_default.warn(`already exists: ${destName}`);
1617
1855
  skipped++;
1618
1856
  continue;
@@ -1621,13 +1859,13 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1621
1859
  moveFolder(entryPath, destPath);
1622
1860
  recordImport(sessionId, entryPath, destPath, "move");
1623
1861
  }
1624
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${destName}`);
1862
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.ps3.encoder(destName)}${typeTag("ps3")}`);
1625
1863
  imported++;
1626
1864
  continue;
1627
1865
  }
1628
1866
  if (detectedType === "book") {
1629
1867
  const destPath = (0, import_path14.resolve)(destRoot, entry);
1630
- if ((0, import_fs13.existsSync)(destPath)) {
1868
+ if ((0, import_fs14.existsSync)(destPath)) {
1631
1869
  spinner_default.warn(`already exists: ${entry}`);
1632
1870
  skipped++;
1633
1871
  continue;
@@ -1636,30 +1874,30 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1636
1874
  if (isDir || isBookDir) {
1637
1875
  moveFolder(entryPath, destPath);
1638
1876
  } else {
1639
- (0, import_fs13.mkdirSync)(destRoot, { recursive: true });
1877
+ (0, import_fs14.mkdirSync)(destRoot, { recursive: true });
1640
1878
  if (sameDev(entryPath, destRoot)) {
1641
- (0, import_fs13.renameSync)(entryPath, destPath);
1879
+ (0, import_fs14.renameSync)(entryPath, destPath);
1642
1880
  } else {
1643
- (0, import_fs13.cpSync)(entryPath, destPath);
1644
- (0, import_fs13.rmSync)(entryPath);
1881
+ (0, import_fs14.cpSync)(entryPath, destPath);
1882
+ (0, import_fs14.rmSync)(entryPath);
1645
1883
  }
1646
1884
  }
1647
1885
  recordImport(sessionId, entryPath, destPath, "move");
1648
1886
  }
1649
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${entry}`);
1887
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.book.encoder(entry)}${typeTag("book")}`);
1650
1888
  imported++;
1651
1889
  continue;
1652
1890
  }
1653
1891
  const parsed = parseDownloadName(entry);
1654
1892
  if (!parsed) {
1655
- if (verbose) spinner_default.info(`could not parse: ${entry}`);
1893
+ if (isVerbose()) spinner_default.info(`could not parse: ${entry}`);
1656
1894
  skipped++;
1657
1895
  continue;
1658
1896
  }
1659
1897
  if (detectedType === "movie") {
1660
1898
  const confidence = classifyMovieConfidence(entry);
1661
1899
  if (confidence === "skip") {
1662
- if (verbose) spinner_default.info(`skipped (uncertain): ${entry}`);
1900
+ if (isVerbose()) spinner_default.info(`skipped (uncertain): ${entry}`);
1663
1901
  skipped++;
1664
1902
  continue;
1665
1903
  }
@@ -1703,7 +1941,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1703
1941
  }
1704
1942
  if (detectedType === "tv") {
1705
1943
  if (parsed.season === void 0) {
1706
- if (verbose) spinner_default.info(`could not detect season from: ${entry}`);
1944
+ if (isVerbose()) spinner_default.info(`could not detect season from: ${entry}`);
1707
1945
  skipped++;
1708
1946
  continue;
1709
1947
  }
@@ -1713,20 +1951,25 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1713
1951
  if (registeredShow) {
1714
1952
  showPath = registeredShow.path;
1715
1953
  showFolderName = showPath.split("/").pop() ?? registeredShow.path;
1716
- } else if (auto) {
1717
- showFolderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear);
1718
- showPath = (0, import_path14.resolve)(destRoot, showFolderName);
1719
- if (!dryRun) upsertShow(showPath, tmdbId ?? null, resolvedTitle);
1720
1954
  } else {
1721
- if (verbose) spinner_default.info(`not registered, skipped: ${resolvedTitle} \u2014 run: reelsort add "${resolvedTitle}"`);
1722
- skipped++;
1723
- continue;
1955
+ const existingFolder = findShowFolder(destRoot, resolvedTitle);
1956
+ if (existingFolder) {
1957
+ showFolderName = existingFolder;
1958
+ showPath = (0, import_path14.resolve)(destRoot, existingFolder);
1959
+ } else if (auto) {
1960
+ showFolderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear);
1961
+ showPath = (0, import_path14.resolve)(destRoot, showFolderName);
1962
+ if (!dryRun) upsertShow(showPath, tmdbId ?? null, resolvedTitle);
1963
+ } else {
1964
+ pendingTv.push({ entry, entryPath, isDir, parsed, resolvedTitle, destRoot });
1965
+ continue;
1966
+ }
1724
1967
  }
1725
1968
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1726
1969
  const seasonPath = (0, import_path14.resolve)(showPath, seasonFolderName);
1727
1970
  const videoFile = isDir ? findVideo(entryPath) : entry;
1728
1971
  if (!videoFile) {
1729
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1972
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1730
1973
  skipped++;
1731
1974
  continue;
1732
1975
  }
@@ -1736,7 +1979,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1736
1979
  const destVideoName = `${episodeName}.${videoExt}`;
1737
1980
  const destVideoPath = (0, import_path14.resolve)(seasonPath, destVideoName);
1738
1981
  const videoSourcePath = isDir ? (0, import_path14.resolve)(entryPath, videoFile) : entryPath;
1739
- if ((0, import_fs13.existsSync)(destVideoPath)) {
1982
+ if ((0, import_fs14.existsSync)(destVideoPath)) {
1740
1983
  let shouldReplace = force;
1741
1984
  if (!shouldReplace && interactive) {
1742
1985
  spinner_default.stop();
@@ -1754,43 +1997,43 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1754
1997
  continue;
1755
1998
  }
1756
1999
  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));
2000
+ for (const f of (0, import_fs14.readdirSync)(seasonPath)) {
2001
+ if (f.startsWith(`${episodeName}.`)) (0, import_fs14.rmSync)((0, import_path14.resolve)(seasonPath, f));
1759
2002
  }
1760
2003
  }
1761
2004
  }
1762
- const dirFiles = isDir ? (0, import_fs13.readdirSync)(entryPath) : [];
2005
+ const dirFiles = isDir ? (0, import_fs14.readdirSync)(entryPath) : [];
1763
2006
  const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1764
2007
  const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1765
2008
  const subtitleSourcePath = subtitle ? (0, import_path14.resolve)(entryPath, subtitle) : null;
1766
2009
  const destSubtitleName = subtitle && subtitleExt ? `${episodeName}.${subtitleExt}` : null;
1767
2010
  if (!dryRun) {
1768
- (0, import_fs13.mkdirSync)(seasonPath, { recursive: true });
2011
+ (0, import_fs14.mkdirSync)(seasonPath, { recursive: true });
1769
2012
  let mode = "move";
1770
2013
  if (useHardlink) {
1771
2014
  try {
1772
2015
  if (!sameDev(videoSourcePath, seasonPath)) throw new Error("cross-filesystem");
1773
- (0, import_fs13.linkSync)(videoSourcePath, destVideoPath);
2016
+ (0, import_fs14.linkSync)(videoSourcePath, destVideoPath);
1774
2017
  mode = "hardlink";
1775
2018
  } catch {
1776
2019
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1777
- (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
2020
+ (0, import_fs14.cpSync)(videoSourcePath, destVideoPath);
1778
2021
  mode = "copy";
1779
2022
  }
1780
- if (subtitleSourcePath && destSubtitleName) (0, import_fs13.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(seasonPath, destSubtitleName));
2023
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs14.cpSync)(subtitleSourcePath, (0, import_path14.resolve)(seasonPath, destSubtitleName));
1781
2024
  } else {
1782
2025
  if (sameDev(videoSourcePath, seasonPath)) {
1783
- (0, import_fs13.renameSync)(videoSourcePath, destVideoPath);
2026
+ (0, import_fs14.renameSync)(videoSourcePath, destVideoPath);
1784
2027
  } else {
1785
- (0, import_fs13.cpSync)(videoSourcePath, destVideoPath);
1786
- (0, import_fs13.rmSync)(videoSourcePath);
2028
+ (0, import_fs14.cpSync)(videoSourcePath, destVideoPath);
2029
+ (0, import_fs14.rmSync)(videoSourcePath);
1787
2030
  }
1788
- if (subtitleSourcePath && destSubtitleName) (0, import_fs13.renameSync)(subtitleSourcePath, (0, import_path14.resolve)(seasonPath, destSubtitleName));
1789
- if (isDir) (0, import_fs13.rmSync)(entryPath, { recursive: true, force: true });
2031
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs14.renameSync)(subtitleSourcePath, (0, import_path14.resolve)(seasonPath, destSubtitleName));
2032
+ if (isDir) (0, import_fs14.rmSync)(entryPath, { recursive: true, force: true });
1790
2033
  }
1791
2034
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
1792
2035
  }
1793
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${showFolderName} / ${seasonFolderName} / ${episodeName}`);
2036
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.tv.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}${typeTag("tv")}`);
1794
2037
  imported++;
1795
2038
  continue;
1796
2039
  }
@@ -1803,7 +2046,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1803
2046
  }
1804
2047
  if (pendingMovies.length > 0) {
1805
2048
  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(/\/$/, "")}`);
2049
+ for (const p of pendingMovies) spinner_default.info(` ${typeColor.movie.encoder("?")} ${p.entry.replace(/\/$/, "")}${typeTag("movie")}`);
1807
2050
  let toProcess = [];
1808
2051
  if (interactive) {
1809
2052
  spinner_default.stop();
@@ -1830,14 +2073,28 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1830
2073
  }
1831
2074
  }
1832
2075
  }
1833
- spinner_default.succeed(`imported ${imported} items`);
2076
+ if (pendingTv.length > 0) {
2077
+ spinner_default.warn(`${pendingTv.length} TV show${pendingTv.length > 1 ? "s" : ""} skipped \u2014 no matching folder in destination`);
2078
+ for (const p of pendingTv) spinner_default.info(` ${typeColor.tv.encoder("?")} ${p.resolvedTitle} \u2014 ${p.entry.replace(/\/$/, "")}${typeTag("tv")}`);
2079
+ skipped += pendingTv.length;
2080
+ }
2081
+ if (ignoreSet.size > 0) {
2082
+ const stale = [...ignoreSet].filter((name) => !seenIgnored.has(name));
2083
+ if (stale.length > 0 && !dryRun) {
2084
+ const updated = config.ignore.filter((name) => !stale.includes(name));
2085
+ config.ignore = updated;
2086
+ saveConfig(config);
2087
+ for (const name of stale) spinner_default.info(`removed from ignore list (not found): ${import_termkit14.Color.white.encoder(name)}`);
2088
+ }
2089
+ }
2090
+ spinner_default.succeed(`${dryRun ? "would import" : "imported"} ${imported} items`);
1834
2091
  if (skipped) spinner_default.info(`skipped ${skipped} items`);
1835
2092
  spinner_default.stop();
1836
2093
  };
1837
2094
  var scan_default = scan;
1838
2095
 
1839
2096
  // src/actions/shows.ts
1840
- var import_fs14 = require("fs");
2097
+ var import_fs15 = require("fs");
1841
2098
  var import_termkit15 = require("termkit");
1842
2099
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
1843
2100
  var shows = async () => {
@@ -1854,7 +2111,7 @@ ${import_termkit15.Color.yellow.encoder("SHOWS")}${destRoot ? ` ${import_termki
1854
2111
  new import_termkit15.Table(
1855
2112
  allShows.map((show) => ({
1856
2113
  name: show.path.split("/").pop() ?? show.path,
1857
- size: (0, import_fs14.existsSync)(show.path) ? formatSize(dirSize(show.path)) : "\u2014",
2114
+ size: (0, import_fs15.existsSync)(show.path) ? formatSize(dirSize(show.path)) : "\u2014",
1858
2115
  tmdbId: show.tmdbId,
1859
2116
  ended: show.ended
1860
2117
  })),
@@ -1887,13 +2144,13 @@ ${import_termkit15.Color.yellow.encoder("SHOWS")}${destRoot ? ` ${import_termki
1887
2144
  var shows_default = shows;
1888
2145
 
1889
2146
  // src/actions/stats.ts
1890
- var import_fs15 = require("fs");
2147
+ var import_fs16 = require("fs");
1891
2148
  var import_path15 = require("path");
1892
2149
  var import_termkit16 = require("termkit");
1893
2150
  var countVideos = (dir) => {
1894
2151
  let count = 0;
1895
2152
  try {
1896
- for (const entry of (0, import_fs15.readdirSync)(dir, { withFileTypes: true })) {
2153
+ for (const entry of (0, import_fs16.readdirSync)(dir, { withFileTypes: true })) {
1897
2154
  if (entry.isDirectory()) {
1898
2155
  count += countVideos((0, import_path15.resolve)(dir, entry.name));
1899
2156
  } else {
@@ -1907,9 +2164,9 @@ var countVideos = (dir) => {
1907
2164
  };
1908
2165
  var countDirs = (dir) => {
1909
2166
  try {
1910
- return (0, import_fs15.readdirSync)(dir).filter((f) => {
2167
+ return (0, import_fs16.readdirSync)(dir).filter((f) => {
1911
2168
  try {
1912
- return (0, import_fs15.lstatSync)((0, import_path15.resolve)(dir, f)).isDirectory();
2169
+ return (0, import_fs16.lstatSync)((0, import_path15.resolve)(dir, f)).isDirectory();
1913
2170
  } catch {
1914
2171
  return false;
1915
2172
  }
@@ -1923,16 +2180,16 @@ var stats = async () => {
1923
2180
  const shows2 = getShows();
1924
2181
  const rows = [];
1925
2182
  const movieDest = config.dest.movie;
1926
- if (movieDest && (0, import_fs15.existsSync)(movieDest)) {
2183
+ if (movieDest && (0, import_fs16.existsSync)(movieDest)) {
1927
2184
  rows.push({ category: "Movies", count: countDirs(movieDest), size: formatSize(dirSize(movieDest)) });
1928
2185
  }
1929
2186
  const tvDest = config.dest.tv;
1930
- if (tvDest && (0, import_fs15.existsSync)(tvDest)) {
2187
+ if (tvDest && (0, import_fs16.existsSync)(tvDest)) {
1931
2188
  rows.push({ category: "Shows", count: shows2.length, size: formatSize(dirSize(tvDest)) });
1932
2189
  rows.push({ category: "Episodes", count: countVideos(tvDest) });
1933
2190
  }
1934
2191
  const ps3Dest = config.dest.ps3;
1935
- if (ps3Dest && (0, import_fs15.existsSync)(ps3Dest)) {
2192
+ if (ps3Dest && (0, import_fs16.existsSync)(ps3Dest)) {
1936
2193
  rows.push({ category: "PS3", count: countDirs(ps3Dest), size: formatSize(dirSize(ps3Dest)) });
1937
2194
  }
1938
2195
  if (rows.length === 0) return;
@@ -1951,7 +2208,7 @@ var stats = async () => {
1951
2208
  var stats_default = stats;
1952
2209
 
1953
2210
  // src/actions/undo.ts
1954
- var import_fs16 = require("fs");
2211
+ var import_fs17 = require("fs");
1955
2212
  var import_termkit17 = require("termkit");
1956
2213
  var undo = async () => {
1957
2214
  spinner_default.start();
@@ -1963,7 +2220,7 @@ var undo = async () => {
1963
2220
  }
1964
2221
  let undone = 0;
1965
2222
  for (const record of records) {
1966
- (0, import_fs16.renameSync)(record.newPath, record.oldPath);
2223
+ (0, import_fs17.renameSync)(record.newPath, record.oldPath);
1967
2224
  spinner_default.succeed(`${import_termkit17.Color.green.encoder(record.newPath)} \u2192 ${import_termkit17.Color.white.encoder(record.oldPath)}`);
1968
2225
  undone++;
1969
2226
  }
@@ -1975,39 +2232,115 @@ var undo_default = undo;
1975
2232
 
1976
2233
  // src/actions/watch.ts
1977
2234
  var import_chokidar = __toESM(require("chokidar"));
1978
- var import_fs17 = require("fs");
2235
+ var import_fs18 = require("fs");
1979
2236
  var import_path16 = require("path");
1980
2237
  var import_termkit18 = require("termkit");
1981
2238
  var sameDev2 = (a, b) => {
1982
2239
  try {
1983
2240
  let bExisting = b;
1984
- while (!(0, import_fs17.existsSync)(bExisting)) bExisting = (0, import_path16.dirname)(bExisting);
1985
- return (0, import_fs17.statSync)(a).dev === (0, import_fs17.statSync)(bExisting).dev;
2241
+ while (!(0, import_fs18.existsSync)(bExisting)) bExisting = (0, import_path16.dirname)(bExisting);
2242
+ return (0, import_fs18.statSync)(a).dev === (0, import_fs18.statSync)(bExisting).dev;
1986
2243
  } catch {
1987
2244
  return false;
1988
2245
  }
1989
2246
  };
1990
2247
  var moveItem = (src, dest) => {
1991
2248
  if (sameDev2(src, dest)) {
1992
- (0, import_fs17.renameSync)(src, dest);
2249
+ (0, import_fs18.renameSync)(src, dest);
1993
2250
  } else {
1994
- (0, import_fs17.cpSync)(src, dest, { recursive: true });
1995
- (0, import_fs17.rmSync)(src, { recursive: true, force: true });
2251
+ (0, import_fs18.cpSync)(src, dest, { recursive: true });
2252
+ (0, import_fs18.rmSync)(src, { recursive: true, force: true });
1996
2253
  }
1997
2254
  };
1998
- var findVideo2 = (dir) => (0, import_fs17.readdirSync)(dir).find((f) => {
2255
+ var findVideo2 = (dir) => (0, import_fs18.readdirSync)(dir).find((f) => {
1999
2256
  const ext = f.match(/([^.]+$)/)?.[0];
2000
2257
  return ext && videoExtensions_default.includes(ext);
2001
2258
  }) ?? null;
2002
- var containsBook2 = (dir) => (0, import_fs17.readdirSync)(dir).some((f) => {
2259
+ var containsBook2 = (dir, depth = 2) => (0, import_fs18.readdirSync)(dir).some((f) => {
2003
2260
  const ext = f.match(/([^.]+$)/)?.[0];
2004
- return ext && bookExtensions_default.includes(ext);
2261
+ if (ext && bookExtensions_default.includes(ext)) return true;
2262
+ if (depth > 1) {
2263
+ try {
2264
+ const sub = (0, import_path16.resolve)(dir, f);
2265
+ if ((0, import_fs18.lstatSync)(sub).isDirectory()) return containsBook2(sub, depth - 1);
2266
+ } catch {
2267
+ }
2268
+ }
2269
+ return false;
2005
2270
  });
2271
+ var isTvEpisodeName2 = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
2272
+ var isSeasonDirName2 = (name) => !isTvEpisodeName2(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
2273
+ var expandWatchPath = (p) => {
2274
+ let isDir;
2275
+ try {
2276
+ isDir = (0, import_fs18.lstatSync)(p).isDirectory();
2277
+ } catch {
2278
+ return [p];
2279
+ }
2280
+ if (!isDir) return [p];
2281
+ const name = (0, import_path16.basename)(p);
2282
+ if (isTvEpisodeName2(name) || /(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name)) return [p];
2283
+ let children;
2284
+ try {
2285
+ children = (0, import_fs18.readdirSync)(p);
2286
+ } catch {
2287
+ return [p];
2288
+ }
2289
+ if (children.some((c) => isTvEpisodeName2(c))) {
2290
+ const entries = [];
2291
+ for (const child of children) {
2292
+ const cp = (0, import_path16.resolve)(p, child);
2293
+ let cd;
2294
+ try {
2295
+ cd = (0, import_fs18.lstatSync)(cp).isDirectory();
2296
+ } catch {
2297
+ continue;
2298
+ }
2299
+ const ext = child.match(/([^.]+$)/)?.[0];
2300
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
2301
+ entries.push(cp);
2302
+ }
2303
+ return entries.length > 0 ? entries : [p];
2304
+ }
2305
+ const seasonDirs = children.filter((c) => {
2306
+ try {
2307
+ return isSeasonDirName2(c) && (0, import_fs18.lstatSync)((0, import_path16.resolve)(p, c)).isDirectory();
2308
+ } catch {
2309
+ return false;
2310
+ }
2311
+ });
2312
+ if (seasonDirs.length > 0) {
2313
+ const entries = [];
2314
+ for (const sd of seasonDirs) {
2315
+ const sp = (0, import_path16.resolve)(p, sd);
2316
+ let sc;
2317
+ try {
2318
+ sc = (0, import_fs18.readdirSync)(sp);
2319
+ } catch {
2320
+ continue;
2321
+ }
2322
+ for (const child of sc) {
2323
+ const cp = (0, import_path16.resolve)(sp, child);
2324
+ let cd;
2325
+ try {
2326
+ cd = (0, import_fs18.lstatSync)(cp).isDirectory();
2327
+ } catch {
2328
+ continue;
2329
+ }
2330
+ const ext = child.match(/([^.]+$)/)?.[0];
2331
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
2332
+ entries.push(cp);
2333
+ }
2334
+ }
2335
+ return entries.length > 0 ? entries : [p];
2336
+ }
2337
+ return [p];
2338
+ };
2006
2339
  var findSeasonFolder2 = (showPath, season) => {
2007
- if (!(0, import_fs17.existsSync)(showPath)) return null;
2008
- const folders = (0, import_fs17.readdirSync)(showPath).filter((f) => {
2340
+ if (!(0, import_fs18.existsSync)(showPath)) return null;
2341
+ const folders = (0, import_fs18.readdirSync)(showPath).filter((f) => {
2009
2342
  try {
2010
- return (0, import_fs17.lstatSync)((0, import_path16.resolve)(showPath, f)).isDirectory();
2343
+ return (0, import_fs18.lstatSync)((0, import_path16.resolve)(showPath, f)).isDirectory();
2011
2344
  } catch {
2012
2345
  return false;
2013
2346
  }
@@ -2017,13 +2350,13 @@ var findSeasonFolder2 = (showPath, season) => {
2017
2350
  return match && parseInt(match[1]) === season;
2018
2351
  }) ?? null;
2019
2352
  };
2020
- var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2353
+ var processItem = async (entryPath, useHardlink, language, auto) => {
2021
2354
  const config = getConfig();
2022
2355
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
2023
2356
  const entry = (0, import_path16.basename)(entryPath);
2024
2357
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
2025
2358
  const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
2026
- const isDir = (0, import_fs17.lstatSync)(entryPath).isDirectory();
2359
+ const isDir = (0, import_fs18.lstatSync)(entryPath).isDirectory();
2027
2360
  const ext = entry.match(/([^.]+$)/)?.[0];
2028
2361
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
2029
2362
  const isBook = !isDir && ext && bookExtensions_default.includes(ext);
@@ -2041,7 +2374,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2041
2374
  }
2042
2375
  const destRoot = config.dest[detectedType];
2043
2376
  if (!destRoot) {
2044
- if (verbose) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
2377
+ if (isVerbose()) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
2045
2378
  return;
2046
2379
  }
2047
2380
  if (detectedType === "ps3") {
@@ -2050,7 +2383,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2050
2383
  if (!nameMatch || !id) return;
2051
2384
  const destName = `${nameMatch[0]} [${id}]`;
2052
2385
  const destPath = (0, import_path16.resolve)(destRoot, destName);
2053
- if ((0, import_fs17.existsSync)(destPath)) {
2386
+ if ((0, import_fs18.existsSync)(destPath)) {
2054
2387
  spinner_default.warn(`already exists: ${destName}`);
2055
2388
  return;
2056
2389
  }
@@ -2061,19 +2394,19 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2061
2394
  }
2062
2395
  if (detectedType === "book") {
2063
2396
  const destPath = (0, import_path16.resolve)(destRoot, entry);
2064
- if ((0, import_fs17.existsSync)(destPath)) {
2397
+ if ((0, import_fs18.existsSync)(destPath)) {
2065
2398
  spinner_default.warn(`already exists: ${entry}`);
2066
2399
  return;
2067
2400
  }
2068
2401
  if (isDir || isBookDir) {
2069
2402
  moveItem(entryPath, destPath);
2070
2403
  } else {
2071
- (0, import_fs17.mkdirSync)(destRoot, { recursive: true });
2404
+ (0, import_fs18.mkdirSync)(destRoot, { recursive: true });
2072
2405
  if (sameDev2(entryPath, destRoot)) {
2073
- (0, import_fs17.renameSync)(entryPath, destPath);
2406
+ (0, import_fs18.renameSync)(entryPath, destPath);
2074
2407
  } else {
2075
- (0, import_fs17.cpSync)(entryPath, destPath);
2076
- (0, import_fs17.rmSync)(entryPath);
2408
+ (0, import_fs18.cpSync)(entryPath, destPath);
2409
+ (0, import_fs18.rmSync)(entryPath);
2077
2410
  }
2078
2411
  }
2079
2412
  recordImport(sessionId, entryPath, destPath, "move");
@@ -2082,12 +2415,12 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2082
2415
  }
2083
2416
  const parsed = parseDownloadName(entry);
2084
2417
  if (!parsed) {
2085
- if (verbose) spinner_default.info(`could not parse: ${entry}`);
2418
+ if (isVerbose()) spinner_default.info(`could not parse: ${entry}`);
2086
2419
  return;
2087
2420
  }
2088
2421
  if (detectedType === "tv") {
2089
2422
  if (parsed.season === void 0) {
2090
- if (verbose) spinner_default.info(`could not detect season from: ${entry}`);
2423
+ if (isVerbose()) spinner_default.info(`could not detect season from: ${entry}`);
2091
2424
  return;
2092
2425
  }
2093
2426
  const registeredShow = getShowByTitle(parsed.title);
@@ -2101,14 +2434,14 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2101
2434
  showPath = (0, import_path16.resolve)(destRoot, showFolderName);
2102
2435
  upsertShow(showPath, null, parsed.title);
2103
2436
  } else {
2104
- if (verbose) spinner_default.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
2437
+ if (isVerbose()) spinner_default.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
2105
2438
  return;
2106
2439
  }
2107
2440
  const seasonFolderName = findSeasonFolder2(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
2108
2441
  const seasonPath = (0, import_path16.resolve)(showPath, seasonFolderName);
2109
2442
  const videoFile2 = isDir ? findVideo2(entryPath) : entry;
2110
2443
  if (!videoFile2) {
2111
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
2444
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
2112
2445
  return;
2113
2446
  }
2114
2447
  const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
@@ -2117,37 +2450,37 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2117
2450
  const destVideoName2 = `${episodeName}.${videoExt2}`;
2118
2451
  const destVideoPath = (0, import_path16.resolve)(seasonPath, destVideoName2);
2119
2452
  const videoSourcePath2 = isDir ? (0, import_path16.resolve)(entryPath, videoFile2) : entryPath;
2120
- if ((0, import_fs17.existsSync)(destVideoPath)) {
2453
+ if ((0, import_fs18.existsSync)(destVideoPath)) {
2121
2454
  spinner_default.warn(`already exists: ${episodeName}`);
2122
2455
  return;
2123
2456
  }
2124
- const dirFiles2 = isDir ? (0, import_fs17.readdirSync)(entryPath) : [];
2457
+ const dirFiles2 = isDir ? (0, import_fs18.readdirSync)(entryPath) : [];
2125
2458
  const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
2126
2459
  const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
2127
2460
  const subtitleSourcePath2 = subtitle2 ? (0, import_path16.resolve)(entryPath, subtitle2) : null;
2128
2461
  const destSubtitleName2 = subtitle2 && subtitleExt2 ? `${episodeName}.${subtitleExt2}` : null;
2129
- (0, import_fs17.mkdirSync)(seasonPath, { recursive: true });
2462
+ (0, import_fs18.mkdirSync)(seasonPath, { recursive: true });
2130
2463
  let mode = "move";
2131
2464
  if (useHardlink) {
2132
2465
  try {
2133
2466
  if (!sameDev2(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
2134
- (0, import_fs17.linkSync)(videoSourcePath2, destVideoPath);
2467
+ (0, import_fs18.linkSync)(videoSourcePath2, destVideoPath);
2135
2468
  mode = "hardlink";
2136
2469
  } catch {
2137
2470
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
2138
- (0, import_fs17.cpSync)(videoSourcePath2, destVideoPath);
2471
+ (0, import_fs18.cpSync)(videoSourcePath2, destVideoPath);
2139
2472
  mode = "copy";
2140
2473
  }
2141
- if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs17.cpSync)(subtitleSourcePath2, (0, import_path16.resolve)(seasonPath, destSubtitleName2));
2474
+ if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs18.cpSync)(subtitleSourcePath2, (0, import_path16.resolve)(seasonPath, destSubtitleName2));
2142
2475
  } else {
2143
2476
  if (sameDev2(videoSourcePath2, seasonPath)) {
2144
- (0, import_fs17.renameSync)(videoSourcePath2, destVideoPath);
2477
+ (0, import_fs18.renameSync)(videoSourcePath2, destVideoPath);
2145
2478
  } else {
2146
- (0, import_fs17.cpSync)(videoSourcePath2, destVideoPath);
2147
- (0, import_fs17.rmSync)(videoSourcePath2);
2479
+ (0, import_fs18.cpSync)(videoSourcePath2, destVideoPath);
2480
+ (0, import_fs18.rmSync)(videoSourcePath2);
2148
2481
  }
2149
- if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs17.renameSync)(subtitleSourcePath2, (0, import_path16.resolve)(seasonPath, destSubtitleName2));
2150
- if (isDir) (0, import_fs17.rmSync)(entryPath, { recursive: true, force: true });
2482
+ if (subtitleSourcePath2 && destSubtitleName2) (0, import_fs18.renameSync)(subtitleSourcePath2, (0, import_path16.resolve)(seasonPath, destSubtitleName2));
2483
+ if (isDir) (0, import_fs18.rmSync)(entryPath, { recursive: true, force: true });
2151
2484
  }
2152
2485
  recordImport(sessionId, entryPath, seasonPath, mode);
2153
2486
  spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
@@ -2156,60 +2489,60 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
2156
2489
  const edition = detectEdition(entry);
2157
2490
  const folderName = formatMovieName(movieFormat, parsed.title, parsed.year, edition);
2158
2491
  const destFolder = (0, import_path16.resolve)(destRoot, folderName);
2159
- if ((0, import_fs17.existsSync)(destFolder)) {
2492
+ if ((0, import_fs18.existsSync)(destFolder)) {
2160
2493
  spinner_default.warn(`already exists: ${folderName}`);
2161
2494
  return;
2162
2495
  }
2163
2496
  const videoFile = isDir ? findVideo2(entryPath) : entry;
2164
2497
  if (!videoFile) {
2165
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
2498
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
2166
2499
  return;
2167
2500
  }
2168
2501
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
2169
2502
  const destVideoName = `${folderName}.${videoExt}`;
2170
2503
  const videoSourcePath = isDir ? (0, import_path16.resolve)(entryPath, videoFile) : entryPath;
2171
- const dirFiles = isDir ? (0, import_fs17.readdirSync)(entryPath) : [];
2504
+ const dirFiles = isDir ? (0, import_fs18.readdirSync)(entryPath) : [];
2172
2505
  const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
2173
2506
  const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
2174
2507
  const subtitleSourcePath = subtitle ? (0, import_path16.resolve)(entryPath, subtitle) : null;
2175
2508
  const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
2176
2509
  if (useHardlink) {
2177
- (0, import_fs17.mkdirSync)(destFolder, { recursive: true });
2510
+ (0, import_fs18.mkdirSync)(destFolder, { recursive: true });
2178
2511
  const destVideoPath = (0, import_path16.resolve)(destFolder, destVideoName);
2179
2512
  let mode;
2180
2513
  try {
2181
2514
  if (!sameDev2(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
2182
- (0, import_fs17.linkSync)(videoSourcePath, destVideoPath);
2515
+ (0, import_fs18.linkSync)(videoSourcePath, destVideoPath);
2183
2516
  mode = "hardlink";
2184
2517
  } catch {
2185
2518
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
2186
- (0, import_fs17.cpSync)(videoSourcePath, destVideoPath);
2519
+ (0, import_fs18.cpSync)(videoSourcePath, destVideoPath);
2187
2520
  mode = "copy";
2188
2521
  }
2189
- if (subtitleSourcePath && destSubtitleName) (0, import_fs17.cpSync)(subtitleSourcePath, (0, import_path16.resolve)(destFolder, destSubtitleName));
2522
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs18.cpSync)(subtitleSourcePath, (0, import_path16.resolve)(destFolder, destSubtitleName));
2190
2523
  recordImport(sessionId, entryPath, destFolder, mode);
2191
2524
  } else {
2192
2525
  if (isDir) {
2193
2526
  const keep = new Set([videoFile, subtitle].filter(Boolean));
2194
- for (const f of dirFiles.filter((f2) => !keep.has(f2))) (0, import_fs17.rmSync)((0, import_path16.resolve)(entryPath, f), { recursive: true, force: true });
2195
- (0, import_fs17.renameSync)(videoSourcePath, (0, import_path16.resolve)(entryPath, destVideoName));
2196
- if (subtitleSourcePath && destSubtitleName) (0, import_fs17.renameSync)(subtitleSourcePath, (0, import_path16.resolve)(entryPath, destSubtitleName));
2527
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) (0, import_fs18.rmSync)((0, import_path16.resolve)(entryPath, f), { recursive: true, force: true });
2528
+ (0, import_fs18.renameSync)(videoSourcePath, (0, import_path16.resolve)(entryPath, destVideoName));
2529
+ if (subtitleSourcePath && destSubtitleName) (0, import_fs18.renameSync)(subtitleSourcePath, (0, import_path16.resolve)(entryPath, destSubtitleName));
2197
2530
  moveItem(entryPath, destFolder);
2198
2531
  } else {
2199
- (0, import_fs17.mkdirSync)(destFolder, { recursive: true });
2532
+ (0, import_fs18.mkdirSync)(destFolder, { recursive: true });
2200
2533
  const destVideoPath = (0, import_path16.resolve)(destFolder, destVideoName);
2201
2534
  if (sameDev2(videoSourcePath, destRoot)) {
2202
- (0, import_fs17.renameSync)(videoSourcePath, destVideoPath);
2535
+ (0, import_fs18.renameSync)(videoSourcePath, destVideoPath);
2203
2536
  } else {
2204
- (0, import_fs17.cpSync)(videoSourcePath, destVideoPath);
2205
- (0, import_fs17.rmSync)(videoSourcePath);
2537
+ (0, import_fs18.cpSync)(videoSourcePath, destVideoPath);
2538
+ (0, import_fs18.rmSync)(videoSourcePath);
2206
2539
  }
2207
2540
  }
2208
2541
  recordImport(sessionId, entryPath, destFolder, "move");
2209
2542
  }
2210
2543
  spinner_default.succeed(`imported ${import_termkit18.Color.green.encoder(folderName)}`);
2211
2544
  };
2212
- var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2545
+ var watch = async ({ hardlink = false, auto = false }) => {
2213
2546
  const config = getConfig();
2214
2547
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
2215
2548
  const language = config.language ?? "eng";
@@ -2222,7 +2555,9 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2222
2555
  setTimeout(async () => {
2223
2556
  pending.delete(path);
2224
2557
  try {
2225
- await processItem(path, hardlink, verbose, language, auto);
2558
+ for (const entry of expandWatchPath(path)) {
2559
+ await processItem(entry, hardlink, language, auto);
2560
+ }
2226
2561
  } catch (err) {
2227
2562
  spinner_default.fail(`error processing ${path}: ${err.message}`);
2228
2563
  }
@@ -2245,19 +2580,20 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2245
2580
  var watch_default = watch;
2246
2581
 
2247
2582
  // package.json
2248
- var version = "0.2.1";
2583
+ var version = "0.2.3";
2249
2584
 
2250
2585
  // src/program.ts
2251
2586
  var toCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
2252
2587
  var adapt = (fn) => (options) => {
2253
2588
  const camel = Object.fromEntries(Object.entries(options).map(([k, v]) => [toCamel(k), v]));
2589
+ setVerbose(!!camel.verbose);
2254
2590
  return fn(camel);
2255
2591
  };
2256
2592
  var { command, option } = import_termkit19.Program;
2257
2593
  var program = import_termkit19.Program.command("reelsort").version(version).description("a cli to manage media").commands([
2258
2594
  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))]),
2595
+ 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))]),
2596
+ 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
2597
  command("set", "<key> <subkey> [value]").description("set a value (e.g. set language eng)").action(adapt(configSet)),
2262
2598
  command("show").description("show current configuration").action(adapt(configShow))
2263
2599
  ]),
@@ -2267,6 +2603,7 @@ var program = import_termkit19.Program.command("reelsort").version(version).desc
2267
2603
  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
2604
  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
2605
  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)),
2606
+ command("ignore", "[name...]").description("ignore source files during scan (interactive picker if no names given)").action(adapt(ignore)).commands([command("remove", "[name]").description("remove files from the ignore list").action(adapt(ignoreRemove))]),
2270
2607
  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
2608
  command("undo").description("undo the last rename session").action(adapt(undo_default)),
2272
2609
  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)),