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.js CHANGED
@@ -366,8 +366,20 @@ var sourceAdd = async ({ dir }) => {
366
366
  spinner_default.stop();
367
367
  };
368
368
  var sourceRemove = async ({ dir }) => {
369
- const resolved = (0, import_path3.resolve)(dir);
370
369
  const config = getConfig();
370
+ if (!dir) {
371
+ if (config.sources.length === 0) {
372
+ spinner_default.start();
373
+ spinner_default.warn("no sources configured");
374
+ spinner_default.stop();
375
+ return;
376
+ }
377
+ const select = new import_termkit3.Select();
378
+ const picked = await select.ask("Which source do you want to remove?", config.sources.map((s) => ({ label: s, value: s })));
379
+ if (!picked) return;
380
+ dir = picked.value;
381
+ }
382
+ const resolved = (0, import_path3.resolve)(dir);
371
383
  const index = config.sources.indexOf(resolved);
372
384
  if (index === -1) {
373
385
  spinner_default.start();
@@ -394,10 +406,23 @@ var destAdd = async ({ type, dir }) => {
394
406
  spinner_default.stop();
395
407
  };
396
408
  var destRemove = async ({ type }) => {
409
+ const config = getConfig();
410
+ if (!type) {
411
+ const configured = DEST_TYPES.filter((t) => config.dest[t]);
412
+ if (configured.length === 0) {
413
+ spinner_default.start();
414
+ spinner_default.warn("no destinations configured");
415
+ spinner_default.stop();
416
+ return;
417
+ }
418
+ const select = new import_termkit3.Select();
419
+ const picked = await select.ask("Which destination do you want to remove?", configured.map((t) => ({ label: `${t.padEnd(6)} ${config.dest[t]}`, value: t })));
420
+ if (!picked) return;
421
+ type = picked.value;
422
+ }
397
423
  if (!DEST_TYPES.includes(type)) {
398
424
  throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
399
425
  }
400
- const config = getConfig();
401
426
  if (!config.dest[type]) {
402
427
  spinner_default.start();
403
428
  spinner_default.warn(`no ${type} destination configured`);
@@ -475,6 +500,12 @@ Subtitle language: ${import_termkit3.Color.green.encoder(config.language ?? "eng
475
500
  console.log(`Movie format: ${import_termkit3.Color.green.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
476
501
  console.log(`Episode format: ${import_termkit3.Color.green.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
477
502
  console.log(`Season folder: ${import_termkit3.Color.green.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
503
+ console.log("\nIgnored files:");
504
+ if (!config.ignore || config.ignore.length === 0) {
505
+ console.log(" (none)");
506
+ } else {
507
+ for (const name of config.ignore) console.log(` ${import_termkit3.Color.white.encoder(name)}`);
508
+ }
478
509
  console.log();
479
510
  };
480
511
 
@@ -782,6 +813,12 @@ var import_child_process = require("child_process");
782
813
  var import_fs7 = require("fs");
783
814
  var import_path8 = require("path");
784
815
  var import_termkit7 = require("termkit");
816
+
817
+ // src/refs/verbose.ts
818
+ var _verbose = false;
819
+ var isVerbose = () => _verbose;
820
+
821
+ // src/actions/probe.ts
785
822
  var DEST_TYPES3 = ["movie", "tv", "ps3"];
786
823
  var CODEC_MAP2 = {
787
824
  hevc: "x265",
@@ -843,7 +880,7 @@ var walkVideoFiles = (dir, depth = 0, maxDepth = 3) => {
843
880
  }
844
881
  return results;
845
882
  };
846
- var probe = async ({ type, force, verbose }) => {
883
+ var probe = async ({ type, force }) => {
847
884
  spinner_default.start();
848
885
  if (!isFfprobeAvailable()) {
849
886
  spinner_default.fail("ffprobe not found \u2014 install ffmpeg to use this command");
@@ -861,19 +898,19 @@ var probe = async ({ type, force, verbose }) => {
861
898
  const files = walkVideoFiles(destRoot);
862
899
  for (const filePath of files) {
863
900
  if (!force && getMediaInfo(filePath)) {
864
- if (verbose) spinner_default.info(`already probed: ${filePath}`);
901
+ if (isVerbose()) spinner_default.info(`already probed: ${filePath}`);
865
902
  skipped++;
866
903
  continue;
867
904
  }
868
905
  spinner_default.text = `probing ${import_termkit7.Color.white.encoder(filePath)}`;
869
906
  const result = runFfprobe(filePath);
870
907
  if (!result) {
871
- if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
908
+ if (isVerbose()) spinner_default.warn(`ffprobe failed: ${filePath}`);
872
909
  failed++;
873
910
  continue;
874
911
  }
875
912
  upsertMediaInfo(filePath, result.codec, result.resolution, result.width, result.height, result.duration);
876
- if (verbose) spinner_default.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
913
+ if (isVerbose()) spinner_default.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
877
914
  probed++;
878
915
  }
879
916
  }
@@ -943,7 +980,7 @@ var titleCase_default = (s) => {
943
980
  };
944
981
 
945
982
  // src/actions/rename.ts
946
- var rename = async ({ dir: inputDir, type, verbose }) => {
983
+ var rename = async ({ dir: inputDir, type }) => {
947
984
  const dir = (0, import_path9.resolve)(inputDir);
948
985
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
949
986
  const config = getConfig();
@@ -957,7 +994,7 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
957
994
  for (const [index, entry] of list2.entries()) {
958
995
  spinner_default.text = `renaming in ${import_termkit8.Color.white.encoder(dir)} ${index + 1}/${list2.length}`;
959
996
  if (!(0, import_fs8.lstatSync)((0, import_path9.resolve)(dir, entry)).isDirectory()) {
960
- if (verbose) spinner_default.info(`skipped ${entry}`);
997
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
961
998
  skipped++;
962
999
  continue;
963
1000
  }
@@ -967,7 +1004,7 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
967
1004
  const nameMatch = entry.match(/(?<=\[).+?(?=\])/);
968
1005
  const id = entry.split("-")[0];
969
1006
  if (!nameMatch || !id) {
970
- if (verbose) spinner_default.info(`skipped ${entry}`);
1007
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
971
1008
  skipped++;
972
1009
  continue;
973
1010
  }
@@ -981,13 +1018,13 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
981
1018
  }
982
1019
  const yearMatch = entry.match(/\([^\d]*(\d+)[^\d]*\)/);
983
1020
  if (!yearMatch) {
984
- if (verbose) spinner_default.info(`skipped ${entry}`);
1021
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
985
1022
  skipped++;
986
1023
  continue;
987
1024
  }
988
1025
  const year = yearMatch[0];
989
1026
  if (year.length !== 6) {
990
- if (verbose) spinner_default.info(`skipped ${entry}`);
1027
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
991
1028
  skipped++;
992
1029
  continue;
993
1030
  }
@@ -998,20 +1035,20 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
998
1035
  return videoExtensions_default.includes(ext2) && title.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
999
1036
  });
1000
1037
  if (!video) {
1001
- if (verbose) spinner_default.info(`skipped ${entry}`);
1038
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1002
1039
  skipped++;
1003
1040
  continue;
1004
1041
  }
1005
1042
  const ext = video.match(/([^.]+$)/)?.[0];
1006
1043
  if (!ext) {
1007
- if (verbose) spinner_default.info(`skipped ${entry}`);
1044
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1008
1045
  skipped++;
1009
1046
  continue;
1010
1047
  }
1011
1048
  const yearNum = parseInt(year.replace(/\D/g, ""));
1012
1049
  const formatted = formatMovieName(movieFormat, title, yearNum);
1013
1050
  if (entry === formatted && video === `${formatted}.${ext}`) {
1014
- if (verbose) spinner_default.info(`skipped ${entry}`);
1051
+ if (isVerbose()) spinner_default.info(`skipped ${entry}`);
1015
1052
  skipped++;
1016
1053
  continue;
1017
1054
  }
@@ -1244,10 +1281,111 @@ var findVideo = (dir) => (0, import_fs10.readdirSync)(dir).find((f) => {
1244
1281
  const ext = f.match(/([^.]+$)/)?.[0];
1245
1282
  return ext && videoExtensions_default.includes(ext);
1246
1283
  }) ?? null;
1247
- var containsBook = (dir) => (0, import_fs10.readdirSync)(dir).some((f) => {
1284
+ var containsBook = (dir, depth = 2) => (0, import_fs10.readdirSync)(dir).some((f) => {
1248
1285
  const ext = f.match(/([^.]+$)/)?.[0];
1249
- return ext && bookExtensions_default.includes(ext);
1286
+ if (ext && bookExtensions_default.includes(ext)) return true;
1287
+ if (depth > 1) {
1288
+ try {
1289
+ const sub = (0, import_path11.resolve)(dir, f);
1290
+ if ((0, import_fs10.lstatSync)(sub).isDirectory()) return containsBook(sub, depth - 1);
1291
+ } catch {
1292
+ }
1293
+ }
1294
+ return false;
1250
1295
  });
1296
+ var isTvEpisodeName = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
1297
+ var isSeasonDirName = (name) => !isTvEpisodeName(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
1298
+ var gatherEntries = (source) => {
1299
+ const result = [];
1300
+ for (const name of (0, import_fs10.readdirSync)(source)) {
1301
+ const fullPath = (0, import_path11.resolve)(source, name);
1302
+ let isDir;
1303
+ try {
1304
+ isDir = (0, import_fs10.lstatSync)(fullPath).isDirectory();
1305
+ } catch {
1306
+ continue;
1307
+ }
1308
+ const ext = name.match(/([^.]+$)/)?.[0];
1309
+ const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1310
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1311
+ if (!isDir && !isVideo && !isBook) continue;
1312
+ if (!isDir) {
1313
+ result.push({ entry: name, entryPath: fullPath, isDir: false });
1314
+ continue;
1315
+ }
1316
+ if (/(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name) || isTvEpisodeName(name)) {
1317
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1318
+ continue;
1319
+ }
1320
+ let children;
1321
+ try {
1322
+ children = (0, import_fs10.readdirSync)(fullPath);
1323
+ } catch {
1324
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1325
+ continue;
1326
+ }
1327
+ if (children.some((c) => isTvEpisodeName(c))) {
1328
+ for (const child of children) {
1329
+ const childPath = (0, import_path11.resolve)(fullPath, child);
1330
+ let childIsDir;
1331
+ try {
1332
+ childIsDir = (0, import_fs10.lstatSync)(childPath).isDirectory();
1333
+ } catch {
1334
+ continue;
1335
+ }
1336
+ const childExt = child.match(/([^.]+$)/)?.[0];
1337
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1338
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1339
+ }
1340
+ continue;
1341
+ }
1342
+ const seasonDirs = children.filter((c) => {
1343
+ try {
1344
+ return isSeasonDirName(c) && (0, import_fs10.lstatSync)((0, import_path11.resolve)(fullPath, c)).isDirectory();
1345
+ } catch {
1346
+ return false;
1347
+ }
1348
+ });
1349
+ if (seasonDirs.length > 0) {
1350
+ for (const seasonDir of seasonDirs) {
1351
+ const seasonPath = (0, import_path11.resolve)(fullPath, seasonDir);
1352
+ let seasonChildren;
1353
+ try {
1354
+ seasonChildren = (0, import_fs10.readdirSync)(seasonPath);
1355
+ } catch {
1356
+ continue;
1357
+ }
1358
+ for (const child of seasonChildren) {
1359
+ const childPath = (0, import_path11.resolve)(seasonPath, child);
1360
+ let childIsDir;
1361
+ try {
1362
+ childIsDir = (0, import_fs10.lstatSync)(childPath).isDirectory();
1363
+ } catch {
1364
+ continue;
1365
+ }
1366
+ const childExt = child.match(/([^.]+$)/)?.[0];
1367
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1368
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1369
+ }
1370
+ }
1371
+ continue;
1372
+ }
1373
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1374
+ }
1375
+ return result;
1376
+ };
1377
+ var findShowFolder = (destRoot, title) => {
1378
+ if (!(0, import_fs10.existsSync)(destRoot)) return null;
1379
+ const normalize = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
1380
+ const target = normalize(title);
1381
+ return (0, import_fs10.readdirSync)(destRoot).filter((f) => {
1382
+ try {
1383
+ return (0, import_fs10.lstatSync)((0, import_path11.resolve)(destRoot, f)).isDirectory();
1384
+ } catch {
1385
+ return false;
1386
+ }
1387
+ }).find((f) => normalize(f) === target) ?? null;
1388
+ };
1251
1389
  var findSeasonFolder = (showPath, season) => {
1252
1390
  if (!(0, import_fs10.existsSync)(showPath)) return null;
1253
1391
  const folders = (0, import_fs10.readdirSync)(showPath).filter((f) => {
@@ -1271,7 +1409,14 @@ var classifyMovieConfidence = (entry) => {
1271
1409
  if (/\.\d{4}\..*?(?:2160p|1080p|720p|BluRay|WEBRip|WEB-DL|HDTV)/i.test(entry)) return "auto";
1272
1410
  return "ambiguous";
1273
1411
  };
1274
- var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, interactive }) => {
1412
+ var typeColor = {
1413
+ movie: import_termkit10.Color.white.cyan,
1414
+ tv: import_termkit10.Color.white.green,
1415
+ book: import_termkit10.Color.white.yellow,
1416
+ ps3: import_termkit10.Color.white.magenta
1417
+ };
1418
+ var typeTag = (t) => isVerbose() ? import_termkit10.Color.white.faint.encoder(` (${t})`) : "";
1419
+ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactive }) => {
1275
1420
  const config = getConfig();
1276
1421
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1277
1422
  const language = config.language ?? "eng";
@@ -1316,7 +1461,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1316
1461
  }
1317
1462
  const videoFile = isDir ? findVideo(entryPath) : entry;
1318
1463
  if (!videoFile) {
1319
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1464
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1320
1465
  return false;
1321
1466
  }
1322
1467
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
@@ -1363,37 +1508,37 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1363
1508
  recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1364
1509
  }
1365
1510
  }
1366
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1511
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.movie.encoder(folderName)}${typeTag("movie")}`);
1367
1512
  return true;
1368
1513
  };
1369
1514
  spinner_default.start();
1370
1515
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1371
1516
  let imported = 0, skipped = 0;
1372
1517
  const pendingMovies = [];
1518
+ const pendingTv = [];
1519
+ const ignoreSet = new Set(config.ignore ?? []);
1520
+ const seenIgnored = /* @__PURE__ */ new Set();
1373
1521
  for (const source of config.sources) {
1374
1522
  if (!(0, import_fs10.existsSync)(source)) {
1375
1523
  spinner_default.warn(`source not found: ${import_termkit10.Color.white.encoder(source)}`);
1376
1524
  continue;
1377
1525
  }
1378
1526
  spinner_default.text = `scanning ${import_termkit10.Color.white.encoder(source)}`;
1379
- for (const entry of (0, import_fs10.readdirSync)(source)) {
1380
- const entryPath = (0, import_path11.resolve)(source, entry);
1381
- const isDir = (0, import_fs10.lstatSync)(entryPath).isDirectory();
1527
+ for (const { entry, entryPath, isDir } of gatherEntries(source)) {
1528
+ if (ignoreSet.has(entry)) {
1529
+ seenIgnored.add(entry);
1530
+ if (isVerbose()) spinner_default.info(`ignored: ${entry}`);
1531
+ continue;
1532
+ }
1382
1533
  const ext = entry.match(/([^.]+$)/)?.[0];
1383
- const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1384
1534
  const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1385
1535
  const isBookDir = isDir && containsBook(entryPath);
1386
- if (!isDir && !isVideo && !isBook) {
1387
- if (verbose) spinner_default.info(`skipped ${entry}`);
1388
- skipped++;
1389
- continue;
1390
- }
1391
1536
  let detectedType;
1392
1537
  if (type) {
1393
1538
  detectedType = type;
1394
1539
  } else if (isBook || isBookDir) {
1395
1540
  detectedType = "book";
1396
- } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1541
+ } else if (isDir && /^[A-Z]{4}\d{5}/i.test(entry)) {
1397
1542
  detectedType = "ps3";
1398
1543
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1399
1544
  detectedType = "tv";
@@ -1402,7 +1547,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1402
1547
  }
1403
1548
  const destRoot = config.dest[detectedType];
1404
1549
  if (!destRoot) {
1405
- if (verbose) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1550
+ if (isVerbose()) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1406
1551
  skipped++;
1407
1552
  continue;
1408
1553
  }
@@ -1424,7 +1569,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1424
1569
  moveFolder(entryPath, destPath);
1425
1570
  recordImport(sessionId, entryPath, destPath, "move");
1426
1571
  }
1427
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${destName}`);
1572
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.ps3.encoder(destName)}${typeTag("ps3")}`);
1428
1573
  imported++;
1429
1574
  continue;
1430
1575
  }
@@ -1449,20 +1594,20 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1449
1594
  }
1450
1595
  recordImport(sessionId, entryPath, destPath, "move");
1451
1596
  }
1452
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${entry}`);
1597
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.book.encoder(entry)}${typeTag("book")}`);
1453
1598
  imported++;
1454
1599
  continue;
1455
1600
  }
1456
1601
  const parsed = parseDownloadName(entry);
1457
1602
  if (!parsed) {
1458
- if (verbose) spinner_default.info(`could not parse: ${entry}`);
1603
+ if (isVerbose()) spinner_default.info(`could not parse: ${entry}`);
1459
1604
  skipped++;
1460
1605
  continue;
1461
1606
  }
1462
1607
  if (detectedType === "movie") {
1463
1608
  const confidence = classifyMovieConfidence(entry);
1464
1609
  if (confidence === "skip") {
1465
- if (verbose) spinner_default.info(`skipped (uncertain): ${entry}`);
1610
+ if (isVerbose()) spinner_default.info(`skipped (uncertain): ${entry}`);
1466
1611
  skipped++;
1467
1612
  continue;
1468
1613
  }
@@ -1506,7 +1651,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1506
1651
  }
1507
1652
  if (detectedType === "tv") {
1508
1653
  if (parsed.season === void 0) {
1509
- if (verbose) spinner_default.info(`could not detect season from: ${entry}`);
1654
+ if (isVerbose()) spinner_default.info(`could not detect season from: ${entry}`);
1510
1655
  skipped++;
1511
1656
  continue;
1512
1657
  }
@@ -1516,20 +1661,25 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1516
1661
  if (registeredShow) {
1517
1662
  showPath = registeredShow.path;
1518
1663
  showFolderName = showPath.split("/").pop() ?? registeredShow.path;
1519
- } else if (auto) {
1520
- showFolderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear);
1521
- showPath = (0, import_path11.resolve)(destRoot, showFolderName);
1522
- if (!dryRun) upsertShow(showPath, tmdbId ?? null, resolvedTitle);
1523
1664
  } else {
1524
- if (verbose) spinner_default.info(`not registered, skipped: ${resolvedTitle} \u2014 run: reelsort add "${resolvedTitle}"`);
1525
- skipped++;
1526
- continue;
1665
+ const existingFolder = findShowFolder(destRoot, resolvedTitle);
1666
+ if (existingFolder) {
1667
+ showFolderName = existingFolder;
1668
+ showPath = (0, import_path11.resolve)(destRoot, existingFolder);
1669
+ } else if (auto) {
1670
+ showFolderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear);
1671
+ showPath = (0, import_path11.resolve)(destRoot, showFolderName);
1672
+ if (!dryRun) upsertShow(showPath, tmdbId ?? null, resolvedTitle);
1673
+ } else {
1674
+ pendingTv.push({ entry, entryPath, isDir, parsed, resolvedTitle, destRoot });
1675
+ continue;
1676
+ }
1527
1677
  }
1528
1678
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1529
1679
  const seasonPath = (0, import_path11.resolve)(showPath, seasonFolderName);
1530
1680
  const videoFile = isDir ? findVideo(entryPath) : entry;
1531
1681
  if (!videoFile) {
1532
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1682
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1533
1683
  skipped++;
1534
1684
  continue;
1535
1685
  }
@@ -1593,7 +1743,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1593
1743
  }
1594
1744
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
1595
1745
  }
1596
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${showFolderName} / ${seasonFolderName} / ${episodeName}`);
1746
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeColor.tv.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}${typeTag("tv")}`);
1597
1747
  imported++;
1598
1748
  continue;
1599
1749
  }
@@ -1606,7 +1756,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1606
1756
  }
1607
1757
  if (pendingMovies.length > 0) {
1608
1758
  spinner_default.warn(`${pendingMovies.length} uncertain movie match${pendingMovies.length > 1 ? "es" : ""} skipped \u2014 use -i to review or -f to import all`);
1609
- for (const p of pendingMovies) spinner_default.info(` ? ${p.entry.replace(/\/$/, "")}`);
1759
+ for (const p of pendingMovies) spinner_default.info(` ${typeColor.movie.encoder("?")} ${p.entry.replace(/\/$/, "")}${typeTag("movie")}`);
1610
1760
  let toProcess = [];
1611
1761
  if (interactive) {
1612
1762
  spinner_default.stop();
@@ -1633,7 +1783,21 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, i
1633
1783
  }
1634
1784
  }
1635
1785
  }
1636
- spinner_default.succeed(`imported ${imported} items`);
1786
+ if (pendingTv.length > 0) {
1787
+ spinner_default.warn(`${pendingTv.length} TV show${pendingTv.length > 1 ? "s" : ""} skipped \u2014 no matching folder in destination`);
1788
+ for (const p of pendingTv) spinner_default.info(` ${typeColor.tv.encoder("?")} ${p.resolvedTitle} \u2014 ${p.entry.replace(/\/$/, "")}${typeTag("tv")}`);
1789
+ skipped += pendingTv.length;
1790
+ }
1791
+ if (ignoreSet.size > 0) {
1792
+ const stale = [...ignoreSet].filter((name) => !seenIgnored.has(name));
1793
+ if (stale.length > 0 && !dryRun) {
1794
+ const updated = config.ignore.filter((name) => !stale.includes(name));
1795
+ config.ignore = updated;
1796
+ saveConfig(config);
1797
+ for (const name of stale) spinner_default.info(`removed from ignore list (not found): ${import_termkit10.Color.white.encoder(name)}`);
1798
+ }
1799
+ }
1800
+ spinner_default.succeed(`${dryRun ? "would import" : "imported"} ${imported} items`);
1637
1801
  if (skipped) spinner_default.info(`skipped ${skipped} items`);
1638
1802
  spinner_default.stop();
1639
1803
  };
@@ -1688,10 +1852,86 @@ var findVideo2 = (dir) => (0, import_fs12.readdirSync)(dir).find((f) => {
1688
1852
  const ext = f.match(/([^.]+$)/)?.[0];
1689
1853
  return ext && videoExtensions_default.includes(ext);
1690
1854
  }) ?? null;
1691
- var containsBook2 = (dir) => (0, import_fs12.readdirSync)(dir).some((f) => {
1855
+ var containsBook2 = (dir, depth = 2) => (0, import_fs12.readdirSync)(dir).some((f) => {
1692
1856
  const ext = f.match(/([^.]+$)/)?.[0];
1693
- return ext && bookExtensions_default.includes(ext);
1857
+ if (ext && bookExtensions_default.includes(ext)) return true;
1858
+ if (depth > 1) {
1859
+ try {
1860
+ const sub = (0, import_path12.resolve)(dir, f);
1861
+ if ((0, import_fs12.lstatSync)(sub).isDirectory()) return containsBook2(sub, depth - 1);
1862
+ } catch {
1863
+ }
1864
+ }
1865
+ return false;
1694
1866
  });
1867
+ var isTvEpisodeName2 = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
1868
+ var isSeasonDirName2 = (name) => !isTvEpisodeName2(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
1869
+ var expandWatchPath = (p) => {
1870
+ let isDir;
1871
+ try {
1872
+ isDir = (0, import_fs12.lstatSync)(p).isDirectory();
1873
+ } catch {
1874
+ return [p];
1875
+ }
1876
+ if (!isDir) return [p];
1877
+ const name = (0, import_path12.basename)(p);
1878
+ if (isTvEpisodeName2(name) || /(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name)) return [p];
1879
+ let children;
1880
+ try {
1881
+ children = (0, import_fs12.readdirSync)(p);
1882
+ } catch {
1883
+ return [p];
1884
+ }
1885
+ if (children.some((c) => isTvEpisodeName2(c))) {
1886
+ const entries = [];
1887
+ for (const child of children) {
1888
+ const cp = (0, import_path12.resolve)(p, child);
1889
+ let cd;
1890
+ try {
1891
+ cd = (0, import_fs12.lstatSync)(cp).isDirectory();
1892
+ } catch {
1893
+ continue;
1894
+ }
1895
+ const ext = child.match(/([^.]+$)/)?.[0];
1896
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
1897
+ entries.push(cp);
1898
+ }
1899
+ return entries.length > 0 ? entries : [p];
1900
+ }
1901
+ const seasonDirs = children.filter((c) => {
1902
+ try {
1903
+ return isSeasonDirName2(c) && (0, import_fs12.lstatSync)((0, import_path12.resolve)(p, c)).isDirectory();
1904
+ } catch {
1905
+ return false;
1906
+ }
1907
+ });
1908
+ if (seasonDirs.length > 0) {
1909
+ const entries = [];
1910
+ for (const sd of seasonDirs) {
1911
+ const sp = (0, import_path12.resolve)(p, sd);
1912
+ let sc;
1913
+ try {
1914
+ sc = (0, import_fs12.readdirSync)(sp);
1915
+ } catch {
1916
+ continue;
1917
+ }
1918
+ for (const child of sc) {
1919
+ const cp = (0, import_path12.resolve)(sp, child);
1920
+ let cd;
1921
+ try {
1922
+ cd = (0, import_fs12.lstatSync)(cp).isDirectory();
1923
+ } catch {
1924
+ continue;
1925
+ }
1926
+ const ext = child.match(/([^.]+$)/)?.[0];
1927
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
1928
+ entries.push(cp);
1929
+ }
1930
+ }
1931
+ return entries.length > 0 ? entries : [p];
1932
+ }
1933
+ return [p];
1934
+ };
1695
1935
  var findSeasonFolder2 = (showPath, season) => {
1696
1936
  if (!(0, import_fs12.existsSync)(showPath)) return null;
1697
1937
  const folders = (0, import_fs12.readdirSync)(showPath).filter((f) => {
@@ -1706,7 +1946,7 @@ var findSeasonFolder2 = (showPath, season) => {
1706
1946
  return match && parseInt(match[1]) === season;
1707
1947
  }) ?? null;
1708
1948
  };
1709
- var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1949
+ var processItem = async (entryPath, useHardlink, language, auto) => {
1710
1950
  const config = getConfig();
1711
1951
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1712
1952
  const entry = (0, import_path12.basename)(entryPath);
@@ -1730,7 +1970,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1730
1970
  }
1731
1971
  const destRoot = config.dest[detectedType];
1732
1972
  if (!destRoot) {
1733
- if (verbose) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1973
+ if (isVerbose()) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1734
1974
  return;
1735
1975
  }
1736
1976
  if (detectedType === "ps3") {
@@ -1771,12 +2011,12 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1771
2011
  }
1772
2012
  const parsed = parseDownloadName(entry);
1773
2013
  if (!parsed) {
1774
- if (verbose) spinner_default.info(`could not parse: ${entry}`);
2014
+ if (isVerbose()) spinner_default.info(`could not parse: ${entry}`);
1775
2015
  return;
1776
2016
  }
1777
2017
  if (detectedType === "tv") {
1778
2018
  if (parsed.season === void 0) {
1779
- if (verbose) spinner_default.info(`could not detect season from: ${entry}`);
2019
+ if (isVerbose()) spinner_default.info(`could not detect season from: ${entry}`);
1780
2020
  return;
1781
2021
  }
1782
2022
  const registeredShow = getShowByTitle(parsed.title);
@@ -1790,14 +2030,14 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1790
2030
  showPath = (0, import_path12.resolve)(destRoot, showFolderName);
1791
2031
  upsertShow(showPath, null, parsed.title);
1792
2032
  } else {
1793
- if (verbose) spinner_default.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
2033
+ if (isVerbose()) spinner_default.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
1794
2034
  return;
1795
2035
  }
1796
2036
  const seasonFolderName = findSeasonFolder2(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1797
2037
  const seasonPath = (0, import_path12.resolve)(showPath, seasonFolderName);
1798
2038
  const videoFile2 = isDir ? findVideo2(entryPath) : entry;
1799
2039
  if (!videoFile2) {
1800
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
2040
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1801
2041
  return;
1802
2042
  }
1803
2043
  const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
@@ -1851,7 +2091,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1851
2091
  }
1852
2092
  const videoFile = isDir ? findVideo2(entryPath) : entry;
1853
2093
  if (!videoFile) {
1854
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
2094
+ if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1855
2095
  return;
1856
2096
  }
1857
2097
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
@@ -1898,7 +2138,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1898
2138
  }
1899
2139
  spinner_default.succeed(`imported ${import_termkit12.Color.green.encoder(folderName)}`);
1900
2140
  };
1901
- var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
2141
+ var watch = async ({ hardlink = false, auto = false }) => {
1902
2142
  const config = getConfig();
1903
2143
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1904
2144
  const language = config.language ?? "eng";
@@ -1911,7 +2151,9 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1911
2151
  setTimeout(async () => {
1912
2152
  pending.delete(path);
1913
2153
  try {
1914
- await processItem(path, hardlink, verbose, language, auto);
2154
+ for (const entry of expandWatchPath(path)) {
2155
+ await processItem(entry, hardlink, language, auto);
2156
+ }
1915
2157
  } catch (err) {
1916
2158
  spinner_default.fail(`error processing ${path}: ${err.message}`);
1917
2159
  }