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/index.mjs CHANGED
@@ -226,7 +226,7 @@ var clean_default = clean;
226
226
 
227
227
  // src/actions/config.ts
228
228
  import { resolve } from "path";
229
- import { Color as Color2 } from "termkit";
229
+ import { Color as Color2, MultiSelect, Select } from "termkit";
230
230
 
231
231
  // src/config.ts
232
232
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
@@ -290,8 +290,20 @@ var sourceAdd = async ({ dir }) => {
290
290
  spinner_default.stop();
291
291
  };
292
292
  var sourceRemove = async ({ dir }) => {
293
- const resolved = resolve(dir);
294
293
  const config = getConfig();
294
+ if (!dir) {
295
+ if (config.sources.length === 0) {
296
+ spinner_default.start();
297
+ spinner_default.warn("no sources configured");
298
+ spinner_default.stop();
299
+ return;
300
+ }
301
+ const select = new Select();
302
+ const picked = await select.ask("Which source do you want to remove?", config.sources.map((s) => ({ label: s, value: s })));
303
+ if (!picked) return;
304
+ dir = picked.value;
305
+ }
306
+ const resolved = resolve(dir);
295
307
  const index = config.sources.indexOf(resolved);
296
308
  if (index === -1) {
297
309
  spinner_default.start();
@@ -318,10 +330,23 @@ var destAdd = async ({ type, dir }) => {
318
330
  spinner_default.stop();
319
331
  };
320
332
  var destRemove = async ({ type }) => {
333
+ const config = getConfig();
334
+ if (!type) {
335
+ const configured = DEST_TYPES.filter((t) => config.dest[t]);
336
+ if (configured.length === 0) {
337
+ spinner_default.start();
338
+ spinner_default.warn("no destinations configured");
339
+ spinner_default.stop();
340
+ return;
341
+ }
342
+ const select = new Select();
343
+ const picked = await select.ask("Which destination do you want to remove?", configured.map((t) => ({ label: `${t.padEnd(6)} ${config.dest[t]}`, value: t })));
344
+ if (!picked) return;
345
+ type = picked.value;
346
+ }
321
347
  if (!DEST_TYPES.includes(type)) {
322
348
  throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
323
349
  }
324
- const config = getConfig();
325
350
  if (!config.dest[type]) {
326
351
  spinner_default.start();
327
352
  spinner_default.warn(`no ${type} destination configured`);
@@ -399,6 +424,12 @@ Subtitle language: ${Color2.green.encoder(config.language ?? "eng (default)")}`)
399
424
  console.log(`Movie format: ${Color2.green.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
400
425
  console.log(`Episode format: ${Color2.green.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
401
426
  console.log(`Season folder: ${Color2.green.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
427
+ console.log("\nIgnored files:");
428
+ if (!config.ignore || config.ignore.length === 0) {
429
+ console.log(" (none)");
430
+ } else {
431
+ for (const name of config.ignore) console.log(` ${Color2.white.encoder(name)}`);
432
+ }
402
433
  console.log();
403
434
  };
404
435
 
@@ -706,6 +737,12 @@ import { spawnSync } from "child_process";
706
737
  import { existsSync as existsSync6, lstatSync as lstatSync2, readdirSync as readdirSync4 } from "fs";
707
738
  import { resolve as resolve5 } from "path";
708
739
  import { Color as Color6 } from "termkit";
740
+
741
+ // src/refs/verbose.ts
742
+ var _verbose = false;
743
+ var isVerbose = () => _verbose;
744
+
745
+ // src/actions/probe.ts
709
746
  var DEST_TYPES3 = ["movie", "tv", "ps3"];
710
747
  var CODEC_MAP2 = {
711
748
  hevc: "x265",
@@ -767,7 +804,7 @@ var walkVideoFiles = (dir, depth = 0, maxDepth = 3) => {
767
804
  }
768
805
  return results;
769
806
  };
770
- var probe = async ({ type, force, verbose }) => {
807
+ var probe = async ({ type, force }) => {
771
808
  spinner_default.start();
772
809
  if (!isFfprobeAvailable()) {
773
810
  spinner_default.fail("ffprobe not found \u2014 install ffmpeg to use this command");
@@ -785,19 +822,19 @@ var probe = async ({ type, force, verbose }) => {
785
822
  const files = walkVideoFiles(destRoot);
786
823
  for (const filePath of files) {
787
824
  if (!force && getMediaInfo(filePath)) {
788
- if (verbose) spinner_default.info(`already probed: ${filePath}`);
825
+ if (isVerbose()) spinner_default.info(`already probed: ${filePath}`);
789
826
  skipped++;
790
827
  continue;
791
828
  }
792
829
  spinner_default.text = `probing ${Color6.white.encoder(filePath)}`;
793
830
  const result = runFfprobe(filePath);
794
831
  if (!result) {
795
- if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
832
+ if (isVerbose()) spinner_default.warn(`ffprobe failed: ${filePath}`);
796
833
  failed++;
797
834
  continue;
798
835
  }
799
836
  upsertMediaInfo(filePath, result.codec, result.resolution, result.width, result.height, result.duration);
800
- if (verbose) spinner_default.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
837
+ if (isVerbose()) spinner_default.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
801
838
  probed++;
802
839
  }
803
840
  }
@@ -867,7 +904,7 @@ var titleCase_default = (s) => {
867
904
  };
868
905
 
869
906
  // src/actions/rename.ts
870
- var rename = async ({ dir: inputDir, type, verbose }) => {
907
+ var rename = async ({ dir: inputDir, type }) => {
871
908
  const dir = resolve6(inputDir);
872
909
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
873
910
  const config = getConfig();
@@ -881,7 +918,7 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
881
918
  for (const [index, entry] of list2.entries()) {
882
919
  spinner_default.text = `renaming in ${Color7.white.encoder(dir)} ${index + 1}/${list2.length}`;
883
920
  if (!lstatSync3(resolve6(dir, entry)).isDirectory()) {
884
- if (verbose) spinner_default.info(`skipped ${entry}`);
921
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
885
922
  skipped++;
886
923
  continue;
887
924
  }
@@ -891,7 +928,7 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
891
928
  const nameMatch = entry.match(/(?<=\[).+?(?=\])/);
892
929
  const id = entry.split("-")[0];
893
930
  if (!nameMatch || !id) {
894
- if (verbose) spinner_default.info(`skipped ${entry}`);
931
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
895
932
  skipped++;
896
933
  continue;
897
934
  }
@@ -905,13 +942,13 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
905
942
  }
906
943
  const yearMatch = entry.match(/\([^\d]*(\d+)[^\d]*\)/);
907
944
  if (!yearMatch) {
908
- if (verbose) spinner_default.info(`skipped ${entry}`);
945
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
909
946
  skipped++;
910
947
  continue;
911
948
  }
912
949
  const year = yearMatch[0];
913
950
  if (year.length !== 6) {
914
- if (verbose) spinner_default.info(`skipped ${entry}`);
951
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
915
952
  skipped++;
916
953
  continue;
917
954
  }
@@ -922,20 +959,20 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
922
959
  return videoExtensions_default.includes(ext2) && title.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
923
960
  });
924
961
  if (!video) {
925
- if (verbose) spinner_default.info(`skipped ${entry}`);
962
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
926
963
  skipped++;
927
964
  continue;
928
965
  }
929
966
  const ext = video.match(/([^.]+$)/)?.[0];
930
967
  if (!ext) {
931
- if (verbose) spinner_default.info(`skipped ${entry}`);
968
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
932
969
  skipped++;
933
970
  continue;
934
971
  }
935
972
  const yearNum = parseInt(year.replace(/\D/g, ""));
936
973
  const formatted = formatMovieName(movieFormat, title, yearNum);
937
974
  if (entry === formatted && video === `${formatted}.${ext}`) {
938
- if (verbose) spinner_default.info(`skipped ${entry}`);
975
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
939
976
  skipped++;
940
977
  continue;
941
978
  }
@@ -1025,7 +1062,7 @@ var reset_default = reset;
1025
1062
  // src/actions/scan.ts
1026
1063
  import { cpSync, existsSync as existsSync9, linkSync, lstatSync as lstatSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync7, renameSync as renameSync3, rmSync as rmSync2, statSync as statSync2 } from "fs";
1027
1064
  import { dirname as dirname2, resolve as resolve8 } from "path";
1028
- import { Color as Color9, MultiSelect, Select } from "termkit";
1065
+ import { Color as Color9, MultiSelect as MultiSelect2, Select as Select2 } from "termkit";
1029
1066
 
1030
1067
  // src/helpers/detectEdition.ts
1031
1068
  var EDITIONS = [
@@ -1168,10 +1205,111 @@ var findVideo = (dir) => readdirSync7(dir).find((f) => {
1168
1205
  const ext = f.match(/([^.]+$)/)?.[0];
1169
1206
  return ext && videoExtensions_default.includes(ext);
1170
1207
  }) ?? null;
1171
- var containsBook = (dir) => readdirSync7(dir).some((f) => {
1208
+ var containsBook = (dir, depth = 2) => readdirSync7(dir).some((f) => {
1172
1209
  const ext = f.match(/([^.]+$)/)?.[0];
1173
- return ext && bookExtensions_default.includes(ext);
1210
+ if (ext && bookExtensions_default.includes(ext)) return true;
1211
+ if (depth > 1) {
1212
+ try {
1213
+ const sub = resolve8(dir, f);
1214
+ if (lstatSync4(sub).isDirectory()) return containsBook(sub, depth - 1);
1215
+ } catch {
1216
+ }
1217
+ }
1218
+ return false;
1174
1219
  });
1220
+ var isTvEpisodeName = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
1221
+ var isSeasonDirName = (name) => !isTvEpisodeName(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
1222
+ var gatherEntries = (source) => {
1223
+ const result = [];
1224
+ for (const name of readdirSync7(source)) {
1225
+ const fullPath = resolve8(source, name);
1226
+ let isDir;
1227
+ try {
1228
+ isDir = lstatSync4(fullPath).isDirectory();
1229
+ } catch {
1230
+ continue;
1231
+ }
1232
+ const ext = name.match(/([^.]+$)/)?.[0];
1233
+ const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1234
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1235
+ if (!isDir && !isVideo && !isBook) continue;
1236
+ if (!isDir) {
1237
+ result.push({ entry: name, entryPath: fullPath, isDir: false });
1238
+ continue;
1239
+ }
1240
+ if (/(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name) || isTvEpisodeName(name)) {
1241
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1242
+ continue;
1243
+ }
1244
+ let children;
1245
+ try {
1246
+ children = readdirSync7(fullPath);
1247
+ } catch {
1248
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1249
+ continue;
1250
+ }
1251
+ if (children.some((c) => isTvEpisodeName(c))) {
1252
+ for (const child of children) {
1253
+ const childPath = resolve8(fullPath, child);
1254
+ let childIsDir;
1255
+ try {
1256
+ childIsDir = lstatSync4(childPath).isDirectory();
1257
+ } catch {
1258
+ continue;
1259
+ }
1260
+ const childExt = child.match(/([^.]+$)/)?.[0];
1261
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1262
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1263
+ }
1264
+ continue;
1265
+ }
1266
+ const seasonDirs = children.filter((c) => {
1267
+ try {
1268
+ return isSeasonDirName(c) && lstatSync4(resolve8(fullPath, c)).isDirectory();
1269
+ } catch {
1270
+ return false;
1271
+ }
1272
+ });
1273
+ if (seasonDirs.length > 0) {
1274
+ for (const seasonDir of seasonDirs) {
1275
+ const seasonPath = resolve8(fullPath, seasonDir);
1276
+ let seasonChildren;
1277
+ try {
1278
+ seasonChildren = readdirSync7(seasonPath);
1279
+ } catch {
1280
+ continue;
1281
+ }
1282
+ for (const child of seasonChildren) {
1283
+ const childPath = resolve8(seasonPath, child);
1284
+ let childIsDir;
1285
+ try {
1286
+ childIsDir = lstatSync4(childPath).isDirectory();
1287
+ } catch {
1288
+ continue;
1289
+ }
1290
+ const childExt = child.match(/([^.]+$)/)?.[0];
1291
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1292
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1293
+ }
1294
+ }
1295
+ continue;
1296
+ }
1297
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1298
+ }
1299
+ return result;
1300
+ };
1301
+ var findShowFolder = (destRoot, title) => {
1302
+ if (!existsSync9(destRoot)) return null;
1303
+ const normalize = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
1304
+ const target = normalize(title);
1305
+ return readdirSync7(destRoot).filter((f) => {
1306
+ try {
1307
+ return lstatSync4(resolve8(destRoot, f)).isDirectory();
1308
+ } catch {
1309
+ return false;
1310
+ }
1311
+ }).find((f) => normalize(f) === target) ?? null;
1312
+ };
1175
1313
  var findSeasonFolder = (showPath, season) => {
1176
1314
  if (!existsSync9(showPath)) return null;
1177
1315
  const folders = readdirSync7(showPath).filter((f) => {
@@ -1195,7 +1333,14 @@ var classifyMovieConfidence = (entry) => {
1195
1333
  if (/\.\d{4}\..*?(?:2160p|1080p|720p|BluRay|WEBRip|WEB-DL|HDTV)/i.test(entry)) return "auto";
1196
1334
  return "ambiguous";
1197
1335
  };
1198
- var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, interactive }) => {
1336
+ var typeColor = {
1337
+ movie: Color9.white.cyan,
1338
+ tv: Color9.white.green,
1339
+ book: Color9.white.yellow,
1340
+ ps3: Color9.white.magenta
1341
+ };
1342
+ var typeTag = (t) => isVerbose() ? Color9.white.faint.encoder(` (${t})`) : "";
1343
+ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactive }) => {
1199
1344
  const config = getConfig();
1200
1345
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1201
1346
  const language = config.language ?? "eng";
@@ -1213,7 +1358,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1213
1358
  resolvedYear = results[0].year ?? parsed.year;
1214
1359
  } else if (results.length > 1) {
1215
1360
  spinner_default.stop();
1216
- const select = new Select();
1361
+ const select = new Select2();
1217
1362
  const items = results.map((r) => ({
1218
1363
  label: r.year ? `${r.title} (${r.year})` : r.title,
1219
1364
  description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
@@ -1240,7 +1385,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1240
1385
  }
1241
1386
  const videoFile = isDir ? findVideo(entryPath) : entry;
1242
1387
  if (!videoFile) {
1243
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1388
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1244
1389
  return false;
1245
1390
  }
1246
1391
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
@@ -1287,37 +1432,37 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1287
1432
  recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1288
1433
  }
1289
1434
  }
1290
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1435
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.movie.encoder(folderName)}${typeTag("movie")}`);
1291
1436
  return true;
1292
1437
  };
1293
1438
  spinner_default.start();
1294
1439
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1295
1440
  let imported = 0, skipped = 0;
1296
1441
  const pendingMovies = [];
1442
+ const pendingTv = [];
1443
+ const ignoreSet = new Set(config.ignore ?? []);
1444
+ const seenIgnored = /* @__PURE__ */ new Set();
1297
1445
  for (const source of config.sources) {
1298
1446
  if (!existsSync9(source)) {
1299
1447
  spinner_default.warn(`source not found: ${Color9.white.encoder(source)}`);
1300
1448
  continue;
1301
1449
  }
1302
1450
  spinner_default.text = `scanning ${Color9.white.encoder(source)}`;
1303
- for (const entry of readdirSync7(source)) {
1304
- const entryPath = resolve8(source, entry);
1305
- const isDir = lstatSync4(entryPath).isDirectory();
1451
+ for (const { entry, entryPath, isDir } of gatherEntries(source)) {
1452
+ if (ignoreSet.has(entry)) {
1453
+ seenIgnored.add(entry);
1454
+ if (isVerbose()) spinner_default.info(`ignored: ${entry}`);
1455
+ continue;
1456
+ }
1306
1457
  const ext = entry.match(/([^.]+$)/)?.[0];
1307
- const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1308
1458
  const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1309
1459
  const isBookDir = isDir && containsBook(entryPath);
1310
- if (!isDir && !isVideo && !isBook) {
1311
- if (verbose) spinner_default.info(`skipped ${entry}`);
1312
- skipped++;
1313
- continue;
1314
- }
1315
1460
  let detectedType;
1316
1461
  if (type) {
1317
1462
  detectedType = type;
1318
1463
  } else if (isBook || isBookDir) {
1319
1464
  detectedType = "book";
1320
- } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1465
+ } else if (isDir && /^[A-Z]{4}\d{5}/i.test(entry)) {
1321
1466
  detectedType = "ps3";
1322
1467
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1323
1468
  detectedType = "tv";
@@ -1326,7 +1471,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1326
1471
  }
1327
1472
  const destRoot = config.dest[detectedType];
1328
1473
  if (!destRoot) {
1329
- if (verbose) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1474
+ if (isVerbose()) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1330
1475
  skipped++;
1331
1476
  continue;
1332
1477
  }
@@ -1348,7 +1493,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1348
1493
  moveFolder(entryPath, destPath);
1349
1494
  recordImport(sessionId, entryPath, destPath, "move");
1350
1495
  }
1351
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${destName}`);
1496
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.ps3.encoder(destName)}${typeTag("ps3")}`);
1352
1497
  imported++;
1353
1498
  continue;
1354
1499
  }
@@ -1373,20 +1518,20 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1373
1518
  }
1374
1519
  recordImport(sessionId, entryPath, destPath, "move");
1375
1520
  }
1376
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${entry}`);
1521
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.book.encoder(entry)}${typeTag("book")}`);
1377
1522
  imported++;
1378
1523
  continue;
1379
1524
  }
1380
1525
  const parsed = parseDownloadName(entry);
1381
1526
  if (!parsed) {
1382
- if (verbose) spinner_default.info(`could not parse: ${entry}`);
1527
+ if (isVerbose()) spinner_default.info(`could not parse: ${entry}`);
1383
1528
  skipped++;
1384
1529
  continue;
1385
1530
  }
1386
1531
  if (detectedType === "movie") {
1387
1532
  const confidence = classifyMovieConfidence(entry);
1388
1533
  if (confidence === "skip") {
1389
- if (verbose) spinner_default.info(`skipped (uncertain): ${entry}`);
1534
+ if (isVerbose()) spinner_default.info(`skipped (uncertain): ${entry}`);
1390
1535
  skipped++;
1391
1536
  continue;
1392
1537
  }
@@ -1407,7 +1552,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1407
1552
  resolvedYear = results[0].year ?? parsed.year;
1408
1553
  } else if (results.length > 1) {
1409
1554
  spinner_default.stop();
1410
- const select = new Select();
1555
+ const select = new Select2();
1411
1556
  const items = results.map((r) => ({
1412
1557
  label: r.year ? `${r.title} (${r.year})` : r.title,
1413
1558
  description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
@@ -1430,7 +1575,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1430
1575
  }
1431
1576
  if (detectedType === "tv") {
1432
1577
  if (parsed.season === void 0) {
1433
- if (verbose) spinner_default.info(`could not detect season from: ${entry}`);
1578
+ if (isVerbose()) spinner_default.info(`could not detect season from: ${entry}`);
1434
1579
  skipped++;
1435
1580
  continue;
1436
1581
  }
@@ -1440,20 +1585,25 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1440
1585
  if (registeredShow) {
1441
1586
  showPath = registeredShow.path;
1442
1587
  showFolderName = showPath.split("/").pop() ?? registeredShow.path;
1443
- } else if (auto) {
1444
- showFolderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear);
1445
- showPath = resolve8(destRoot, showFolderName);
1446
- if (!dryRun) upsertShow(showPath, tmdbId ?? null, resolvedTitle);
1447
1588
  } else {
1448
- if (verbose) spinner_default.info(`not registered, skipped: ${resolvedTitle} \u2014 run: reelsort add "${resolvedTitle}"`);
1449
- skipped++;
1450
- continue;
1589
+ const existingFolder = findShowFolder(destRoot, resolvedTitle);
1590
+ if (existingFolder) {
1591
+ showFolderName = existingFolder;
1592
+ showPath = resolve8(destRoot, existingFolder);
1593
+ } else if (auto) {
1594
+ showFolderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear);
1595
+ showPath = resolve8(destRoot, showFolderName);
1596
+ if (!dryRun) upsertShow(showPath, tmdbId ?? null, resolvedTitle);
1597
+ } else {
1598
+ pendingTv.push({ entry, entryPath, isDir, parsed, resolvedTitle, destRoot });
1599
+ continue;
1600
+ }
1451
1601
  }
1452
1602
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1453
1603
  const seasonPath = resolve8(showPath, seasonFolderName);
1454
1604
  const videoFile = isDir ? findVideo(entryPath) : entry;
1455
1605
  if (!videoFile) {
1456
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1606
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1457
1607
  skipped++;
1458
1608
  continue;
1459
1609
  }
@@ -1467,7 +1617,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1467
1617
  let shouldReplace = force;
1468
1618
  if (!shouldReplace && interactive) {
1469
1619
  spinner_default.stop();
1470
- const select = new Select();
1620
+ const select = new Select2();
1471
1621
  const picked = await select.ask(`Already exists \u2014 replace?`, [
1472
1622
  { label: `${showFolderName} / ${seasonFolderName} / ${episodeName}`, value: "replace" },
1473
1623
  { label: "Skip", value: "skip" }
@@ -1517,7 +1667,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1517
1667
  }
1518
1668
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
1519
1669
  }
1520
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${showFolderName} / ${seasonFolderName} / ${episodeName}`);
1670
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.tv.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}${typeTag("tv")}`);
1521
1671
  imported++;
1522
1672
  continue;
1523
1673
  }
@@ -1530,11 +1680,11 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1530
1680
  }
1531
1681
  if (pendingMovies.length > 0) {
1532
1682
  spinner_default.warn(`${pendingMovies.length} uncertain movie match${pendingMovies.length > 1 ? "es" : ""} skipped \u2014 use -i to review or -f to import all`);
1533
- for (const p of pendingMovies) spinner_default.info(` ? ${p.entry.replace(/\/$/, "")}`);
1683
+ for (const p of pendingMovies) spinner_default.info(` ${typeColor.movie.encoder("?")} ${p.entry.replace(/\/$/, "")}${typeTag("movie")}`);
1534
1684
  let toProcess = [];
1535
1685
  if (interactive) {
1536
1686
  spinner_default.stop();
1537
- const ms = new MultiSelect({ allowSkip: true, search: true, maxHeight: 20 });
1687
+ const ms = new MultiSelect2({ allowSkip: true, search: true, maxHeight: 20 });
1538
1688
  const items = pendingMovies.map((p) => ({
1539
1689
  label: p.entry.replace(/\/$/, ""),
1540
1690
  description: p.parsed.year ? `parsed: ${p.parsed.title} \xB7 ${p.parsed.year}` : `parsed: ${p.parsed.title}`,
@@ -1557,7 +1707,21 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1557
1707
  }
1558
1708
  }
1559
1709
  }
1560
- spinner_default.succeed(`imported ${imported} items`);
1710
+ if (pendingTv.length > 0) {
1711
+ spinner_default.warn(`${pendingTv.length} TV show${pendingTv.length > 1 ? "s" : ""} skipped \u2014 no matching folder in destination`);
1712
+ for (const p of pendingTv) spinner_default.info(` ${typeColor.tv.encoder("?")} ${p.resolvedTitle} \u2014 ${p.entry.replace(/\/$/, "")}${typeTag("tv")}`);
1713
+ skipped += pendingTv.length;
1714
+ }
1715
+ if (ignoreSet.size > 0) {
1716
+ const stale = [...ignoreSet].filter((name) => !seenIgnored.has(name));
1717
+ if (stale.length > 0 && !dryRun) {
1718
+ const updated = config.ignore.filter((name) => !stale.includes(name));
1719
+ config.ignore = updated;
1720
+ saveConfig(config);
1721
+ for (const name of stale) spinner_default.info(`removed from ignore list (not found): ${Color9.white.encoder(name)}`);
1722
+ }
1723
+ }
1724
+ spinner_default.succeed(`${dryRun ? "would import" : "imported"} ${imported} items`);
1561
1725
  if (skipped) spinner_default.info(`skipped ${skipped} items`);
1562
1726
  spinner_default.stop();
1563
1727
  };
@@ -1612,10 +1776,86 @@ var findVideo2 = (dir) => readdirSync8(dir).find((f) => {
1612
1776
  const ext = f.match(/([^.]+$)/)?.[0];
1613
1777
  return ext && videoExtensions_default.includes(ext);
1614
1778
  }) ?? null;
1615
- var containsBook2 = (dir) => readdirSync8(dir).some((f) => {
1779
+ var containsBook2 = (dir, depth = 2) => readdirSync8(dir).some((f) => {
1616
1780
  const ext = f.match(/([^.]+$)/)?.[0];
1617
- return ext && bookExtensions_default.includes(ext);
1781
+ if (ext && bookExtensions_default.includes(ext)) return true;
1782
+ if (depth > 1) {
1783
+ try {
1784
+ const sub = resolve9(dir, f);
1785
+ if (lstatSync5(sub).isDirectory()) return containsBook2(sub, depth - 1);
1786
+ } catch {
1787
+ }
1788
+ }
1789
+ return false;
1618
1790
  });
1791
+ var isTvEpisodeName2 = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
1792
+ var isSeasonDirName2 = (name) => !isTvEpisodeName2(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
1793
+ var expandWatchPath = (p) => {
1794
+ let isDir;
1795
+ try {
1796
+ isDir = lstatSync5(p).isDirectory();
1797
+ } catch {
1798
+ return [p];
1799
+ }
1800
+ if (!isDir) return [p];
1801
+ const name = basename3(p);
1802
+ if (isTvEpisodeName2(name) || /(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name)) return [p];
1803
+ let children;
1804
+ try {
1805
+ children = readdirSync8(p);
1806
+ } catch {
1807
+ return [p];
1808
+ }
1809
+ if (children.some((c) => isTvEpisodeName2(c))) {
1810
+ const entries = [];
1811
+ for (const child of children) {
1812
+ const cp = resolve9(p, child);
1813
+ let cd;
1814
+ try {
1815
+ cd = lstatSync5(cp).isDirectory();
1816
+ } catch {
1817
+ continue;
1818
+ }
1819
+ const ext = child.match(/([^.]+$)/)?.[0];
1820
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
1821
+ entries.push(cp);
1822
+ }
1823
+ return entries.length > 0 ? entries : [p];
1824
+ }
1825
+ const seasonDirs = children.filter((c) => {
1826
+ try {
1827
+ return isSeasonDirName2(c) && lstatSync5(resolve9(p, c)).isDirectory();
1828
+ } catch {
1829
+ return false;
1830
+ }
1831
+ });
1832
+ if (seasonDirs.length > 0) {
1833
+ const entries = [];
1834
+ for (const sd of seasonDirs) {
1835
+ const sp = resolve9(p, sd);
1836
+ let sc;
1837
+ try {
1838
+ sc = readdirSync8(sp);
1839
+ } catch {
1840
+ continue;
1841
+ }
1842
+ for (const child of sc) {
1843
+ const cp = resolve9(sp, child);
1844
+ let cd;
1845
+ try {
1846
+ cd = lstatSync5(cp).isDirectory();
1847
+ } catch {
1848
+ continue;
1849
+ }
1850
+ const ext = child.match(/([^.]+$)/)?.[0];
1851
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
1852
+ entries.push(cp);
1853
+ }
1854
+ }
1855
+ return entries.length > 0 ? entries : [p];
1856
+ }
1857
+ return [p];
1858
+ };
1619
1859
  var findSeasonFolder2 = (showPath, season) => {
1620
1860
  if (!existsSync10(showPath)) return null;
1621
1861
  const folders = readdirSync8(showPath).filter((f) => {
@@ -1630,7 +1870,7 @@ var findSeasonFolder2 = (showPath, season) => {
1630
1870
  return match && parseInt(match[1]) === season;
1631
1871
  }) ?? null;
1632
1872
  };
1633
- var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1873
+ var processItem = async (entryPath, useHardlink, language, auto) => {
1634
1874
  const config = getConfig();
1635
1875
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1636
1876
  const entry = basename3(entryPath);
@@ -1654,7 +1894,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1654
1894
  }
1655
1895
  const destRoot = config.dest[detectedType];
1656
1896
  if (!destRoot) {
1657
- if (verbose) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1897
+ if (isVerbose()) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1658
1898
  return;
1659
1899
  }
1660
1900
  if (detectedType === "ps3") {
@@ -1695,12 +1935,12 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1695
1935
  }
1696
1936
  const parsed = parseDownloadName(entry);
1697
1937
  if (!parsed) {
1698
- if (verbose) spinner_default.info(`could not parse: ${entry}`);
1938
+ if (isVerbose()) spinner_default.info(`could not parse: ${entry}`);
1699
1939
  return;
1700
1940
  }
1701
1941
  if (detectedType === "tv") {
1702
1942
  if (parsed.season === void 0) {
1703
- if (verbose) spinner_default.info(`could not detect season from: ${entry}`);
1943
+ if (isVerbose()) spinner_default.info(`could not detect season from: ${entry}`);
1704
1944
  return;
1705
1945
  }
1706
1946
  const registeredShow = getShowByTitle(parsed.title);
@@ -1714,14 +1954,14 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1714
1954
  showPath = resolve9(destRoot, showFolderName);
1715
1955
  upsertShow(showPath, null, parsed.title);
1716
1956
  } else {
1717
- if (verbose) spinner_default.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
1957
+ if (isVerbose()) spinner_default.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
1718
1958
  return;
1719
1959
  }
1720
1960
  const seasonFolderName = findSeasonFolder2(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1721
1961
  const seasonPath = resolve9(showPath, seasonFolderName);
1722
1962
  const videoFile2 = isDir ? findVideo2(entryPath) : entry;
1723
1963
  if (!videoFile2) {
1724
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1964
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1725
1965
  return;
1726
1966
  }
1727
1967
  const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
@@ -1775,7 +2015,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1775
2015
  }
1776
2016
  const videoFile = isDir ? findVideo2(entryPath) : entry;
1777
2017
  if (!videoFile) {
1778
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
2018
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1779
2019
  return;
1780
2020
  }
1781
2021
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
@@ -1822,7 +2062,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1822
2062
  }
1823
2063
  spinner_default.succeed(`imported ${Color11.green.encoder(folderName)}`);
1824
2064
  };
1825
- var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2065
+ var watch = async ({ hardlink = false, auto = false }) => {
1826
2066
  const config = getConfig();
1827
2067
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1828
2068
  const language = config.language ?? "eng";
@@ -1835,7 +2075,9 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1835
2075
  setTimeout(async () => {
1836
2076
  pending.delete(path);
1837
2077
  try {
1838
- await processItem(path, hardlink, verbose, language, auto);
2078
+ for (const entry of expandWatchPath(path)) {
2079
+ await processItem(entry, hardlink, language, auto);
2080
+ }
1839
2081
  } catch (err) {
1840
2082
  spinner_default.fail(`error processing ${path}: ${err.message}`);
1841
2083
  }