reelsort 0.2.0 → 0.2.2

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
@@ -204,17 +204,17 @@ var clean = async ({ dryRun, olderThan }) => {
204
204
  continue;
205
205
  }
206
206
  if (dryRun) {
207
- spinner_default.succeed(`[dry] would remove ${Color.blue.encoder(imp.sourcePath)}`);
207
+ spinner_default.succeed(`[dry] would remove ${Color.white.encoder(imp.sourcePath)}`);
208
208
  cleaned++;
209
209
  continue;
210
210
  }
211
211
  try {
212
212
  rmSync(imp.sourcePath, { recursive: true, force: true });
213
213
  deleteImport(imp.id);
214
- spinner_default.succeed(`removed ${Color.blue.encoder(imp.sourcePath)}`);
214
+ spinner_default.succeed(`removed ${Color.white.encoder(imp.sourcePath)}`);
215
215
  cleaned++;
216
216
  } catch {
217
- spinner_default.warn(`locked or inaccessible, skipped: ${Color.blue.encoder(imp.sourcePath)}`);
217
+ spinner_default.warn(`locked or inaccessible, skipped: ${Color.white.encoder(imp.sourcePath)}`);
218
218
  skipped++;
219
219
  }
220
220
  }
@@ -273,20 +273,20 @@ var formatMovieName = (template, title, year, edition) => {
273
273
  };
274
274
 
275
275
  // src/actions/config.ts
276
- var DEST_TYPES = ["movie", "tv", "ps3"];
276
+ var DEST_TYPES = ["movie", "tv", "ps3", "book"];
277
277
  var sourceAdd = async ({ dir }) => {
278
278
  const resolved = resolve(dir);
279
279
  const config = getConfig();
280
280
  if (config.sources.includes(resolved)) {
281
281
  spinner_default.start();
282
- spinner_default.info(`source already configured: ${Color2.blue.encoder(resolved)}`);
282
+ spinner_default.info(`source already configured: ${Color2.white.encoder(resolved)}`);
283
283
  spinner_default.stop();
284
284
  return;
285
285
  }
286
286
  config.sources.push(resolved);
287
287
  saveConfig(config);
288
288
  spinner_default.start();
289
- spinner_default.succeed(`added source: ${Color2.blue.encoder(resolved)}`);
289
+ spinner_default.succeed(`added source: ${Color2.white.encoder(resolved)}`);
290
290
  spinner_default.stop();
291
291
  };
292
292
  var sourceRemove = async ({ dir }) => {
@@ -295,14 +295,14 @@ var sourceRemove = async ({ dir }) => {
295
295
  const index = config.sources.indexOf(resolved);
296
296
  if (index === -1) {
297
297
  spinner_default.start();
298
- spinner_default.warn(`source not found: ${Color2.blue.encoder(resolved)}`);
298
+ spinner_default.warn(`source not found: ${Color2.white.encoder(resolved)}`);
299
299
  spinner_default.stop();
300
300
  return;
301
301
  }
302
302
  config.sources.splice(index, 1);
303
303
  saveConfig(config);
304
304
  spinner_default.start();
305
- spinner_default.succeed(`removed source: ${Color2.blue.encoder(resolved)}`);
305
+ spinner_default.succeed(`removed source: ${Color2.white.encoder(resolved)}`);
306
306
  spinner_default.stop();
307
307
  };
308
308
  var destAdd = async ({ type, dir }) => {
@@ -314,7 +314,7 @@ var destAdd = async ({ type, dir }) => {
314
314
  config.dest[type] = resolved;
315
315
  saveConfig(config);
316
316
  spinner_default.start();
317
- spinner_default.succeed(`set ${type} destination: ${Color2.cyan.encoder(resolved)}`);
317
+ spinner_default.succeed(`set ${type} destination: ${Color2.green.encoder(resolved)}`);
318
318
  spinner_default.stop();
319
319
  };
320
320
  var destRemove = async ({ type }) => {
@@ -340,7 +340,7 @@ var configSet = async ({ key, subkey, value }) => {
340
340
  config.language = subkey;
341
341
  saveConfig(config);
342
342
  spinner_default.start();
343
- spinner_default.succeed(`set subtitle language: ${Color2.cyan.encoder(subkey)}`);
343
+ spinner_default.succeed(`set subtitle language: ${Color2.green.encoder(subkey)}`);
344
344
  spinner_default.stop();
345
345
  return;
346
346
  }
@@ -370,7 +370,7 @@ var configSet = async ({ key, subkey, value }) => {
370
370
  }
371
371
  saveConfig(config);
372
372
  spinner_default.start();
373
- spinner_default.succeed(`set ${subkey} format: ${Color2.cyan.encoder(value ?? subkey)}`);
373
+ spinner_default.succeed(`set ${subkey} format: ${Color2.green.encoder(value ?? subkey)}`);
374
374
  spinner_default.stop();
375
375
  return;
376
376
  }
@@ -382,7 +382,7 @@ var configShow = async () => {
382
382
  if (config.sources.length === 0) {
383
383
  console.log(" (none)");
384
384
  } else {
385
- for (const s of config.sources) console.log(` ${Color2.blue.encoder(s)}`);
385
+ for (const s of config.sources) console.log(` ${Color2.white.encoder(s)}`);
386
386
  }
387
387
  console.log("\nDestinations:");
388
388
  const entries = DEST_TYPES.map((t) => ({ type: t, path: config.dest[t] })).filter((e) => e.path);
@@ -390,15 +390,15 @@ var configShow = async () => {
390
390
  console.log(" (none)");
391
391
  } else {
392
392
  for (const { type, path } of entries) {
393
- console.log(` ${type.padEnd(6)} ${Color2.cyan.encoder(path)}`);
393
+ console.log(` ${type.padEnd(6)} ${Color2.green.encoder(path)}`);
394
394
  }
395
395
  }
396
396
  console.log(`
397
- Subtitle language: ${Color2.cyan.encoder(config.language ?? "eng (default)")}`);
397
+ Subtitle language: ${Color2.green.encoder(config.language ?? "eng (default)")}`);
398
398
  console.log(`TMDb API key: ${config.tmdbApiKey ? Color2.green.encoder("configured") : Color2.red.encoder("not set")}`);
399
- console.log(`Movie format: ${Color2.cyan.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
400
- console.log(`Episode format: ${Color2.cyan.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
401
- console.log(`Season folder: ${Color2.cyan.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
399
+ console.log(`Movie format: ${Color2.green.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
400
+ console.log(`Episode format: ${Color2.green.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
401
+ console.log(`Season folder: ${Color2.green.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
402
402
  console.log();
403
403
  };
404
404
 
@@ -409,7 +409,7 @@ import { Color as Color3 } from "termkit";
409
409
  var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
410
410
  let dir1 = rawDir1;
411
411
  let dir2 = rawDir2;
412
- spinner_default.text = `checking differences between ${Color3.blue.encoder(dir1)} and ${Color3.blue.encoder(dir2)}`;
412
+ spinner_default.text = `checking differences between ${Color3.white.encoder(dir1)} and ${Color3.white.encoder(dir2)}`;
413
413
  spinner_default.start();
414
414
  dir1 = resolve2(dir1);
415
415
  dir2 = resolve2(dir2);
@@ -445,7 +445,7 @@ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
445
445
  removed.push(l);
446
446
  }
447
447
  }
448
- spinner_default.succeed(`checked differences between ${Color3.blue.encoder(dir1)} and ${Color3.blue.encoder(dir2)}`);
448
+ spinner_default.succeed(`checked differences between ${Color3.white.encoder(dir1)} and ${Color3.white.encoder(dir2)}`);
449
449
  spinner_default.succeed(`found ${added.length} added files`);
450
450
  spinner_default.succeed(`found ${removed.length} removed files`);
451
451
  spinner_default.stop();
@@ -471,8 +471,8 @@ var history = async ({ limit, imports }) => {
471
471
  ${Color4.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
472
472
  for (const r of session.records) {
473
473
  const src = basename(r.sourcePath);
474
- const dest = Color4.cyan.encoder(r.destinationPath);
475
- const mode = r.mode !== "move" ? ` ${Color4.blue.encoder(`[${r.mode}]`)}` : "";
474
+ const dest = Color4.green.encoder(r.destinationPath);
475
+ const mode = r.mode !== "move" ? ` ${Color4.white.encoder(`[${r.mode}]`)}` : "";
476
476
  console.log(` ${src} \u2192 ${dest}${mode}`);
477
477
  }
478
478
  }
@@ -491,7 +491,7 @@ ${Color4.yellow.encoder(label)} (${folders.length} item${folders.length !== 1 ?
491
491
  for (const r of folders) {
492
492
  const oldName = basename(r.oldPath);
493
493
  const newName = basename(r.newPath);
494
- console.log(` ${Color4.blue.encoder(oldName)} \u2192 ${Color4.cyan.encoder(newName)}`);
494
+ console.log(` ${Color4.white.encoder(oldName)} \u2192 ${Color4.green.encoder(newName)}`);
495
495
  }
496
496
  }
497
497
  }
@@ -642,7 +642,7 @@ var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter
642
642
  const destRoot = config.dest[t];
643
643
  if (!existsSync5(destRoot)) {
644
644
  console.log(`
645
- ${t.toUpperCase()} ${Color5.blue.encoder(destRoot)} (not found)`);
645
+ ${t.toUpperCase()} ${Color5.white.encoder(destRoot)} (not found)`);
646
646
  continue;
647
647
  }
648
648
  const folders = readdirSync3(destRoot).filter((f) => {
@@ -673,7 +673,7 @@ ${t.toUpperCase()} ${Color5.blue.encoder(destRoot)} (not found)`);
673
673
  return yearDiff !== 0 ? yearDiff : a.title.localeCompare(b.title);
674
674
  });
675
675
  console.log(`
676
- ${Color5.yellow.encoder(t.toUpperCase())} ${Color5.blue.encoder(destRoot)}`);
676
+ ${Color5.yellow.encoder(t.toUpperCase())} ${Color5.white.encoder(destRoot)}`);
677
677
  new Table(
678
678
  filtered.map((e) => ({
679
679
  title: e.title,
@@ -781,7 +781,7 @@ var probe = async ({ type, force, verbose }) => {
781
781
  for (const t of types) {
782
782
  const destRoot = config.dest[t];
783
783
  if (!existsSync6(destRoot)) continue;
784
- spinner_default.text = `scanning ${Color6.blue.encoder(destRoot)}`;
784
+ spinner_default.text = `scanning ${Color6.white.encoder(destRoot)}`;
785
785
  const files = walkVideoFiles(destRoot);
786
786
  for (const filePath of files) {
787
787
  if (!force && getMediaInfo(filePath)) {
@@ -789,7 +789,7 @@ var probe = async ({ type, force, verbose }) => {
789
789
  skipped++;
790
790
  continue;
791
791
  }
792
- spinner_default.text = `probing ${Color6.blue.encoder(filePath)}`;
792
+ spinner_default.text = `probing ${Color6.white.encoder(filePath)}`;
793
793
  const result = runFfprobe(filePath);
794
794
  if (!result) {
795
795
  if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
@@ -873,13 +873,13 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
873
873
  const config = getConfig();
874
874
  const language = config.language ?? "eng";
875
875
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
876
- spinner_default.text = `renaming in ${Color7.blue.encoder(dir)}`;
876
+ spinner_default.text = `renaming in ${Color7.white.encoder(dir)}`;
877
877
  spinner_default.start();
878
878
  if (!existsSync7(dir)) throw new Error(`dir ${dir} does not exist`);
879
879
  const list2 = readdirSync5(dir);
880
880
  let renamed = 0, removed = 0, skipped = 0;
881
881
  for (const [index, entry] of list2.entries()) {
882
- spinner_default.text = `renaming in ${Color7.blue.encoder(dir)} ${index + 1}/${list2.length}`;
882
+ spinner_default.text = `renaming in ${Color7.white.encoder(dir)} ${index + 1}/${list2.length}`;
883
883
  if (!lstatSync3(resolve6(dir, entry)).isDirectory()) {
884
884
  if (verbose) spinner_default.info(`skipped ${entry}`);
885
885
  skipped++;
@@ -964,7 +964,7 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
964
964
  spinner_default.succeed(`renamed ${renamed} files`);
965
965
  if (removed) spinner_default.info(`removed ${removed} files`);
966
966
  spinner_default.info(`skipped ${skipped} files`);
967
- spinner_default.succeed(`done in ${Color7.cyan.encoder(dir)}`);
967
+ spinner_default.succeed(`done in ${Color7.green.encoder(dir)}`);
968
968
  spinner_default.stop();
969
969
  };
970
970
  var rename_default = rename;
@@ -975,7 +975,7 @@ import { basename as basename2, dirname, resolve as resolve7, sep } from "path";
975
975
  import { Color as Color8 } from "termkit";
976
976
  var reset = async ({ dir: inputDir, double }) => {
977
977
  let dir = inputDir;
978
- spinner_default.text = `resetting episodes in ${Color8.blue.encoder(dir)}`;
978
+ spinner_default.text = `resetting episodes in ${Color8.white.encoder(dir)}`;
979
979
  spinner_default.start();
980
980
  dir = resolve7(dir);
981
981
  if (!existsSync8(dir)) throw new Error(`dir ${dir} does not exist`);
@@ -1004,7 +1004,7 @@ var reset = async ({ dir: inputDir, double }) => {
1004
1004
  const episodeFormat = getConfig().format?.episode;
1005
1005
  let renamed = 0, skipped = other.length;
1006
1006
  for (const [index, i] of sublist.entries()) {
1007
- spinner_default.text = `resetting episodes in ${Color8.blue.encoder(dir)} ${index}/${list2.length}`;
1007
+ spinner_default.text = `resetting episodes in ${Color8.white.encoder(dir)} ${index}/${list2.length}`;
1008
1008
  const ext = i.match(/([^.]+$)/)?.[0];
1009
1009
  const episode = double ? index * 2 + 1 : index + 1;
1010
1010
  const name = `${formatEpisode(seasonNum, episode, episodeFormat, double, showTitle)}.${ext}`;
@@ -1017,7 +1017,7 @@ var reset = async ({ dir: inputDir, double }) => {
1017
1017
  }
1018
1018
  spinner_default.succeed(`renamed ${renamed} files`);
1019
1019
  spinner_default.info(`skipped ${skipped} files`);
1020
- spinner_default.succeed(`done in ${Color8.cyan.encoder(dir)}`);
1020
+ spinner_default.succeed(`done in ${Color8.green.encoder(dir)}`);
1021
1021
  spinner_default.stop();
1022
1022
  };
1023
1023
  var reset_default = reset;
@@ -1025,7 +1025,7 @@ var reset_default = reset;
1025
1025
  // src/actions/scan.ts
1026
1026
  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
1027
  import { dirname as dirname2, resolve as resolve8 } from "path";
1028
- import { Color as Color9, Select } from "termkit";
1028
+ import { Color as Color9, MultiSelect, Select } from "termkit";
1029
1029
 
1030
1030
  // src/helpers/detectEdition.ts
1031
1031
  var EDITIONS = [
@@ -1098,18 +1098,17 @@ var searchMovie = async (title, year, apiKey) => {
1098
1098
  if (year) url.searchParams.set("year", String(year));
1099
1099
  try {
1100
1100
  const res = await fetch(url.toString());
1101
- if (!res.ok) return null;
1101
+ if (!res.ok) return [];
1102
1102
  const data = await res.json();
1103
- const first = data.results[0];
1104
- if (!first) return null;
1105
- return {
1106
- id: first.id,
1107
- title: first.title,
1108
- year: first.release_date ? parseInt(first.release_date.slice(0, 4)) : void 0,
1109
- url: `${TMDB_WEB}/movie/${first.id}`
1110
- };
1103
+ return data.results.slice(0, 5).map((r) => ({
1104
+ id: r.id,
1105
+ title: r.title,
1106
+ year: r.release_date ? parseInt(r.release_date.slice(0, 4)) : void 0,
1107
+ overview: r.overview || void 0,
1108
+ url: `${TMDB_WEB}/movie/${r.id}`
1109
+ }));
1111
1110
  } catch {
1112
- return null;
1111
+ return [];
1113
1112
  }
1114
1113
  };
1115
1114
  var getEpisodeName = async (seriesId, season, episode, apiKey) => {
@@ -1144,6 +1143,9 @@ var searchTv = async (title, apiKey) => {
1144
1143
  }
1145
1144
  };
1146
1145
 
1146
+ // src/refs/bookExtensions.json
1147
+ var bookExtensions_default = ["epub", "mobi", "azw3", "azw"];
1148
+
1147
1149
  // src/actions/scan.ts
1148
1150
  var sameDev = (a, b) => {
1149
1151
  try {
@@ -1166,6 +1168,99 @@ var findVideo = (dir) => readdirSync7(dir).find((f) => {
1166
1168
  const ext = f.match(/([^.]+$)/)?.[0];
1167
1169
  return ext && videoExtensions_default.includes(ext);
1168
1170
  }) ?? null;
1171
+ var containsBook = (dir, depth = 2) => readdirSync7(dir).some((f) => {
1172
+ const ext = f.match(/([^.]+$)/)?.[0];
1173
+ if (ext && bookExtensions_default.includes(ext)) return true;
1174
+ if (depth > 1) {
1175
+ try {
1176
+ const sub = resolve8(dir, f);
1177
+ if (lstatSync4(sub).isDirectory()) return containsBook(sub, depth - 1);
1178
+ } catch {
1179
+ }
1180
+ }
1181
+ return false;
1182
+ });
1183
+ var isTvEpisodeName = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
1184
+ var isSeasonDirName = (name) => !isTvEpisodeName(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
1185
+ var gatherEntries = (source) => {
1186
+ const result = [];
1187
+ for (const name of readdirSync7(source)) {
1188
+ const fullPath = resolve8(source, name);
1189
+ let isDir;
1190
+ try {
1191
+ isDir = lstatSync4(fullPath).isDirectory();
1192
+ } catch {
1193
+ continue;
1194
+ }
1195
+ const ext = name.match(/([^.]+$)/)?.[0];
1196
+ const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1197
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1198
+ if (!isDir && !isVideo && !isBook) continue;
1199
+ if (!isDir) {
1200
+ result.push({ entry: name, entryPath: fullPath, isDir: false });
1201
+ continue;
1202
+ }
1203
+ if (/(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name) || isTvEpisodeName(name)) {
1204
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1205
+ continue;
1206
+ }
1207
+ let children;
1208
+ try {
1209
+ children = readdirSync7(fullPath);
1210
+ } catch {
1211
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1212
+ continue;
1213
+ }
1214
+ if (children.some((c) => isTvEpisodeName(c))) {
1215
+ for (const child of children) {
1216
+ const childPath = resolve8(fullPath, child);
1217
+ let childIsDir;
1218
+ try {
1219
+ childIsDir = lstatSync4(childPath).isDirectory();
1220
+ } catch {
1221
+ continue;
1222
+ }
1223
+ const childExt = child.match(/([^.]+$)/)?.[0];
1224
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1225
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1226
+ }
1227
+ continue;
1228
+ }
1229
+ const seasonDirs = children.filter((c) => {
1230
+ try {
1231
+ return isSeasonDirName(c) && lstatSync4(resolve8(fullPath, c)).isDirectory();
1232
+ } catch {
1233
+ return false;
1234
+ }
1235
+ });
1236
+ if (seasonDirs.length > 0) {
1237
+ for (const seasonDir of seasonDirs) {
1238
+ const seasonPath = resolve8(fullPath, seasonDir);
1239
+ let seasonChildren;
1240
+ try {
1241
+ seasonChildren = readdirSync7(seasonPath);
1242
+ } catch {
1243
+ continue;
1244
+ }
1245
+ for (const child of seasonChildren) {
1246
+ const childPath = resolve8(seasonPath, child);
1247
+ let childIsDir;
1248
+ try {
1249
+ childIsDir = lstatSync4(childPath).isDirectory();
1250
+ } catch {
1251
+ continue;
1252
+ }
1253
+ const childExt = child.match(/([^.]+$)/)?.[0];
1254
+ if (!childIsDir && !(childExt && videoExtensions_default.includes(childExt))) continue;
1255
+ result.push({ entry: child, entryPath: childPath, isDir: childIsDir });
1256
+ }
1257
+ }
1258
+ continue;
1259
+ }
1260
+ result.push({ entry: name, entryPath: fullPath, isDir: true });
1261
+ }
1262
+ return result;
1263
+ };
1169
1264
  var findSeasonFolder = (showPath, season) => {
1170
1265
  if (!existsSync9(showPath)) return null;
1171
1266
  const folders = readdirSync7(showPath).filter((f) => {
@@ -1180,35 +1275,130 @@ var findSeasonFolder = (showPath, season) => {
1180
1275
  return match && parseInt(match[1]) === season;
1181
1276
  }) ?? null;
1182
1277
  };
1183
- var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1278
+ var classifyMovieConfidence = (entry) => {
1279
+ if (/^\[(?:Team|Group)\s/i.test(entry)) return "skip";
1280
+ if (/\bepisodes?\s+\d+[-–]\d+/i.test(entry)) return "skip";
1281
+ if (/\b(?:patch|keygen|crack)\b|\bkeys?\s*\{/i.test(entry)) return "skip";
1282
+ if (/\[YTS[.\-]/i.test(entry)) return "auto";
1283
+ if (/\(\d{4}\)/.test(entry) && /\[(?:2160p|1080p|720p|480p|576p|BluRay|BDRip|BDRemux|WEBRip|WEB-DL|HDRip|DVDRip|HDTV)/i.test(entry)) return "auto";
1284
+ if (/\.\d{4}\..*?(?:2160p|1080p|720p|BluRay|WEBRip|WEB-DL|HDTV)/i.test(entry)) return "auto";
1285
+ return "ambiguous";
1286
+ };
1287
+ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, interactive }) => {
1184
1288
  const config = getConfig();
1185
1289
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1186
1290
  const language = config.language ?? "eng";
1187
1291
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1188
1292
  const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
1293
+ const lookupMovie = async (parsed) => {
1294
+ let tmdbId;
1295
+ let resolvedTitle = parsed.title;
1296
+ let resolvedYear = parsed.year;
1297
+ if (config.tmdbApiKey) {
1298
+ const results = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1299
+ if (results.length === 1) {
1300
+ tmdbId = results[0].id;
1301
+ resolvedTitle = results[0].title;
1302
+ resolvedYear = results[0].year ?? parsed.year;
1303
+ } else if (results.length > 1) {
1304
+ spinner_default.stop();
1305
+ const select = new Select();
1306
+ const items = results.map((r) => ({
1307
+ label: r.year ? `${r.title} (${r.year})` : r.title,
1308
+ description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
1309
+ ...r
1310
+ }));
1311
+ const picked = await select.ask(`Multiple movies found for "${parsed.title}":`, items);
1312
+ spinner_default.start();
1313
+ if (picked) {
1314
+ tmdbId = picked.id;
1315
+ resolvedTitle = picked.title;
1316
+ resolvedYear = picked.year ?? parsed.year;
1317
+ }
1318
+ }
1319
+ }
1320
+ return { tmdbId, resolvedTitle, resolvedYear };
1321
+ };
1322
+ const importMovie = async (entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot) => {
1323
+ const edition = detectEdition(entry);
1324
+ const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1325
+ const destFolder = resolve8(destRoot, folderName);
1326
+ if (existsSync9(destFolder)) {
1327
+ spinner_default.warn(`already exists: ${folderName}`);
1328
+ return false;
1329
+ }
1330
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1331
+ if (!videoFile) {
1332
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1333
+ return false;
1334
+ }
1335
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1336
+ const destVideoName = `${folderName}.${videoExt}`;
1337
+ const videoSourcePath = isDir ? resolve8(entryPath, videoFile) : entryPath;
1338
+ const dirFiles = isDir ? readdirSync7(entryPath) : [];
1339
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1340
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1341
+ const subtitleSourcePath = subtitle ? resolve8(entryPath, subtitle) : null;
1342
+ const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1343
+ if (!dryRun) {
1344
+ if (useHardlink) {
1345
+ mkdirSync3(destFolder, { recursive: true });
1346
+ const destVideoPath = resolve8(destFolder, destVideoName);
1347
+ let mode;
1348
+ try {
1349
+ if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1350
+ linkSync(videoSourcePath, destVideoPath);
1351
+ mode = "hardlink";
1352
+ } catch {
1353
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1354
+ cpSync(videoSourcePath, destVideoPath);
1355
+ mode = "copy";
1356
+ }
1357
+ if (subtitleSourcePath && destSubtitleName) cpSync(subtitleSourcePath, resolve8(destFolder, destSubtitleName));
1358
+ recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1359
+ } else {
1360
+ if (isDir) {
1361
+ const keep = new Set([videoFile, subtitle].filter(Boolean));
1362
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) rmSync2(resolve8(entryPath, f), { recursive: true, force: true });
1363
+ renameSync3(videoSourcePath, resolve8(entryPath, destVideoName));
1364
+ if (subtitleSourcePath && destSubtitleName) renameSync3(subtitleSourcePath, resolve8(entryPath, destSubtitleName));
1365
+ moveFolder(entryPath, destFolder);
1366
+ } else {
1367
+ mkdirSync3(destFolder, { recursive: true });
1368
+ const destVideoPath = resolve8(destFolder, destVideoName);
1369
+ if (sameDev(videoSourcePath, destRoot)) {
1370
+ renameSync3(videoSourcePath, destVideoPath);
1371
+ } else {
1372
+ cpSync(videoSourcePath, destVideoPath);
1373
+ rmSync2(videoSourcePath);
1374
+ }
1375
+ }
1376
+ recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1377
+ }
1378
+ }
1379
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1380
+ return true;
1381
+ };
1189
1382
  spinner_default.start();
1190
1383
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1191
1384
  let imported = 0, skipped = 0;
1385
+ const pendingMovies = [];
1192
1386
  for (const source of config.sources) {
1193
1387
  if (!existsSync9(source)) {
1194
- spinner_default.warn(`source not found: ${Color9.blue.encoder(source)}`);
1388
+ spinner_default.warn(`source not found: ${Color9.white.encoder(source)}`);
1195
1389
  continue;
1196
1390
  }
1197
- spinner_default.text = `scanning ${Color9.blue.encoder(source)}`;
1198
- for (const entry of readdirSync7(source)) {
1199
- const entryPath = resolve8(source, entry);
1200
- const isDir = lstatSync4(entryPath).isDirectory();
1391
+ spinner_default.text = `scanning ${Color9.white.encoder(source)}`;
1392
+ for (const { entry, entryPath, isDir } of gatherEntries(source)) {
1201
1393
  const ext = entry.match(/([^.]+$)/)?.[0];
1202
- const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1203
- if (!isDir && !isVideo) {
1204
- if (verbose) spinner_default.info(`skipped ${entry}`);
1205
- skipped++;
1206
- continue;
1207
- }
1394
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1395
+ const isBookDir = isDir && containsBook(entryPath);
1208
1396
  let detectedType;
1209
1397
  if (type) {
1210
1398
  detectedType = type;
1211
- } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1399
+ } else if (isBook || isBookDir) {
1400
+ detectedType = "book";
1401
+ } else if (isDir && /^[A-Z]{4}\d{5}/i.test(entry)) {
1212
1402
  detectedType = "ps3";
1213
1403
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1214
1404
  detectedType = "tv";
@@ -1243,12 +1433,49 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1243
1433
  imported++;
1244
1434
  continue;
1245
1435
  }
1436
+ if (detectedType === "book") {
1437
+ const destPath = resolve8(destRoot, entry);
1438
+ if (existsSync9(destPath)) {
1439
+ spinner_default.warn(`already exists: ${entry}`);
1440
+ skipped++;
1441
+ continue;
1442
+ }
1443
+ if (!dryRun) {
1444
+ if (isDir || isBookDir) {
1445
+ moveFolder(entryPath, destPath);
1446
+ } else {
1447
+ mkdirSync3(destRoot, { recursive: true });
1448
+ if (sameDev(entryPath, destRoot)) {
1449
+ renameSync3(entryPath, destPath);
1450
+ } else {
1451
+ cpSync(entryPath, destPath);
1452
+ rmSync2(entryPath);
1453
+ }
1454
+ }
1455
+ recordImport(sessionId, entryPath, destPath, "move");
1456
+ }
1457
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${entry}`);
1458
+ imported++;
1459
+ continue;
1460
+ }
1246
1461
  const parsed = parseDownloadName(entry);
1247
1462
  if (!parsed) {
1248
1463
  if (verbose) spinner_default.info(`could not parse: ${entry}`);
1249
1464
  skipped++;
1250
1465
  continue;
1251
1466
  }
1467
+ if (detectedType === "movie") {
1468
+ const confidence = classifyMovieConfidence(entry);
1469
+ if (confidence === "skip") {
1470
+ if (verbose) spinner_default.info(`skipped (uncertain): ${entry}`);
1471
+ skipped++;
1472
+ continue;
1473
+ }
1474
+ if (confidence === "ambiguous") {
1475
+ pendingMovies.push({ entry, entryPath, isDir, parsed, destRoot });
1476
+ continue;
1477
+ }
1478
+ }
1252
1479
  let tmdbId;
1253
1480
  let resolvedTitle = parsed.title;
1254
1481
  let resolvedYear = parsed.year;
@@ -1276,12 +1503,10 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1276
1503
  }
1277
1504
  }
1278
1505
  } else {
1279
- const tmdb = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1280
- if (tmdb) {
1281
- tmdbId = tmdb.id;
1282
- resolvedTitle = tmdb.title;
1283
- resolvedYear = tmdb.year ?? parsed.year;
1284
- }
1506
+ const result = await lookupMovie(parsed);
1507
+ tmdbId = result.tmdbId;
1508
+ resolvedTitle = result.resolvedTitle;
1509
+ resolvedYear = result.resolvedYear;
1285
1510
  }
1286
1511
  }
1287
1512
  if (detectedType === "tv") {
@@ -1307,50 +1532,68 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1307
1532
  }
1308
1533
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1309
1534
  const seasonPath = resolve8(showPath, seasonFolderName);
1310
- const videoFile2 = isDir ? findVideo(entryPath) : entry;
1311
- if (!videoFile2) {
1535
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1536
+ if (!videoFile) {
1312
1537
  if (verbose) spinner_default.info(`no video found in: ${entry}`);
1313
1538
  skipped++;
1314
1539
  continue;
1315
1540
  }
1316
- const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
1541
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1317
1542
  const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1318
1543
  const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, resolvedTitle, tmdbEpisodeName ?? void 0);
1319
- const destVideoName2 = `${episodeName}.${videoExt2}`;
1320
- const destVideoPath = resolve8(seasonPath, destVideoName2);
1321
- const videoSourcePath2 = isDir ? resolve8(entryPath, videoFile2) : entryPath;
1544
+ const destVideoName = `${episodeName}.${videoExt}`;
1545
+ const destVideoPath = resolve8(seasonPath, destVideoName);
1546
+ const videoSourcePath = isDir ? resolve8(entryPath, videoFile) : entryPath;
1322
1547
  if (existsSync9(destVideoPath)) {
1323
- spinner_default.warn(`already exists: ${episodeName}`);
1324
- skipped++;
1325
- continue;
1548
+ let shouldReplace = force;
1549
+ if (!shouldReplace && interactive) {
1550
+ spinner_default.stop();
1551
+ const select = new Select();
1552
+ const picked = await select.ask(`Already exists \u2014 replace?`, [
1553
+ { label: `${showFolderName} / ${seasonFolderName} / ${episodeName}`, value: "replace" },
1554
+ { label: "Skip", value: "skip" }
1555
+ ]);
1556
+ spinner_default.start();
1557
+ shouldReplace = picked?.value === "replace";
1558
+ }
1559
+ if (!shouldReplace) {
1560
+ spinner_default.warn(`already exists: ${episodeName}`);
1561
+ skipped++;
1562
+ continue;
1563
+ }
1564
+ if (!dryRun) {
1565
+ for (const f of readdirSync7(seasonPath)) {
1566
+ if (f.startsWith(`${episodeName}.`)) rmSync2(resolve8(seasonPath, f));
1567
+ }
1568
+ }
1326
1569
  }
1327
- const dirFiles2 = isDir ? readdirSync7(entryPath) : [];
1328
- const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
1329
- const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
1330
- const subtitleSourcePath2 = subtitle2 ? resolve8(entryPath, subtitle2) : null;
1331
- const destSubtitleName2 = subtitle2 && subtitleExt2 ? `${episodeName}.${subtitleExt2}` : null;
1570
+ const dirFiles = isDir ? readdirSync7(entryPath) : [];
1571
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1572
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1573
+ const subtitleSourcePath = subtitle ? resolve8(entryPath, subtitle) : null;
1574
+ const destSubtitleName = subtitle && subtitleExt ? `${episodeName}.${subtitleExt}` : null;
1332
1575
  if (!dryRun) {
1333
1576
  mkdirSync3(seasonPath, { recursive: true });
1334
1577
  let mode = "move";
1335
1578
  if (useHardlink) {
1336
1579
  try {
1337
- if (!sameDev(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1338
- linkSync(videoSourcePath2, destVideoPath);
1580
+ if (!sameDev(videoSourcePath, seasonPath)) throw new Error("cross-filesystem");
1581
+ linkSync(videoSourcePath, destVideoPath);
1339
1582
  mode = "hardlink";
1340
1583
  } catch {
1341
1584
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1342
- cpSync(videoSourcePath2, destVideoPath);
1585
+ cpSync(videoSourcePath, destVideoPath);
1343
1586
  mode = "copy";
1344
1587
  }
1345
- if (subtitleSourcePath2 && destSubtitleName2) cpSync(subtitleSourcePath2, resolve8(seasonPath, destSubtitleName2));
1588
+ if (subtitleSourcePath && destSubtitleName) cpSync(subtitleSourcePath, resolve8(seasonPath, destSubtitleName));
1346
1589
  } else {
1347
- if (sameDev(videoSourcePath2, seasonPath)) {
1348
- renameSync3(videoSourcePath2, destVideoPath);
1590
+ if (sameDev(videoSourcePath, seasonPath)) {
1591
+ renameSync3(videoSourcePath, destVideoPath);
1349
1592
  } else {
1350
- cpSync(videoSourcePath2, destVideoPath);
1351
- rmSync2(videoSourcePath2);
1593
+ cpSync(videoSourcePath, destVideoPath);
1594
+ rmSync2(videoSourcePath);
1352
1595
  }
1353
- if (subtitleSourcePath2 && destSubtitleName2) renameSync3(subtitleSourcePath2, resolve8(seasonPath, destSubtitleName2));
1596
+ if (subtitleSourcePath && destSubtitleName) renameSync3(subtitleSourcePath, resolve8(seasonPath, destSubtitleName));
1354
1597
  if (isDir) rmSync2(entryPath, { recursive: true, force: true });
1355
1598
  }
1356
1599
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
@@ -1359,69 +1602,43 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1359
1602
  imported++;
1360
1603
  continue;
1361
1604
  }
1362
- const edition = detectEdition(entry);
1363
- const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1364
- const destFolder = resolve8(destRoot, folderName);
1365
- if (existsSync9(destFolder)) {
1366
- spinner_default.warn(`already exists: ${folderName}`);
1605
+ if (await importMovie(entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot)) {
1606
+ imported++;
1607
+ } else {
1367
1608
  skipped++;
1368
- continue;
1369
1609
  }
1370
- const videoFile = isDir ? findVideo(entryPath) : entry;
1371
- if (!videoFile) {
1372
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1610
+ }
1611
+ }
1612
+ if (pendingMovies.length > 0) {
1613
+ spinner_default.warn(`${pendingMovies.length} uncertain movie match${pendingMovies.length > 1 ? "es" : ""} skipped \u2014 use -i to review or -f to import all`);
1614
+ for (const p of pendingMovies) spinner_default.info(` ? ${p.entry.replace(/\/$/, "")}`);
1615
+ let toProcess = [];
1616
+ if (interactive) {
1617
+ spinner_default.stop();
1618
+ const ms = new MultiSelect({ allowSkip: true, search: true, maxHeight: 20 });
1619
+ const items = pendingMovies.map((p) => ({
1620
+ label: p.entry.replace(/\/$/, ""),
1621
+ description: p.parsed.year ? `parsed: ${p.parsed.title} \xB7 ${p.parsed.year}` : `parsed: ${p.parsed.title}`,
1622
+ ...p
1623
+ }));
1624
+ toProcess = await ms.ask("Select entries to import as movies:", items) ?? [];
1625
+ spinner_default.start();
1626
+ skipped += pendingMovies.length - toProcess.length;
1627
+ } else if (force) {
1628
+ toProcess = pendingMovies;
1629
+ } else {
1630
+ skipped += pendingMovies.length;
1631
+ }
1632
+ for (const p of toProcess) {
1633
+ const { tmdbId, resolvedTitle, resolvedYear } = await lookupMovie(p.parsed);
1634
+ if (await importMovie(p.entry, p.entryPath, p.isDir, resolvedTitle, resolvedYear, tmdbId, p.destRoot)) {
1635
+ imported++;
1636
+ } else {
1373
1637
  skipped++;
1374
- continue;
1375
- }
1376
- const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1377
- const destVideoName = `${folderName}.${videoExt}`;
1378
- const videoSourcePath = isDir ? resolve8(entryPath, videoFile) : entryPath;
1379
- const dirFiles = isDir ? readdirSync7(entryPath) : [];
1380
- const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1381
- const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1382
- const subtitleSourcePath = subtitle ? resolve8(entryPath, subtitle) : null;
1383
- const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1384
- if (!dryRun) {
1385
- if (useHardlink) {
1386
- mkdirSync3(destFolder, { recursive: true });
1387
- const destVideoPath = resolve8(destFolder, destVideoName);
1388
- let mode;
1389
- try {
1390
- if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1391
- linkSync(videoSourcePath, destVideoPath);
1392
- mode = "hardlink";
1393
- } catch {
1394
- spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1395
- cpSync(videoSourcePath, destVideoPath);
1396
- mode = "copy";
1397
- }
1398
- if (subtitleSourcePath && destSubtitleName) cpSync(subtitleSourcePath, resolve8(destFolder, destSubtitleName));
1399
- recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1400
- } else {
1401
- if (isDir) {
1402
- const keep = new Set([videoFile, subtitle].filter(Boolean));
1403
- for (const f of dirFiles.filter((f2) => !keep.has(f2))) rmSync2(resolve8(entryPath, f), { recursive: true, force: true });
1404
- renameSync3(videoSourcePath, resolve8(entryPath, destVideoName));
1405
- if (subtitleSourcePath && destSubtitleName) renameSync3(subtitleSourcePath, resolve8(entryPath, destSubtitleName));
1406
- moveFolder(entryPath, destFolder);
1407
- } else {
1408
- mkdirSync3(destFolder, { recursive: true });
1409
- const destVideoPath = resolve8(destFolder, destVideoName);
1410
- if (sameDev(videoSourcePath, destRoot)) {
1411
- renameSync3(videoSourcePath, destVideoPath);
1412
- } else {
1413
- cpSync(videoSourcePath, destVideoPath);
1414
- rmSync2(videoSourcePath);
1415
- }
1416
- }
1417
- recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1418
- }
1419
1638
  }
1420
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1421
- imported++;
1422
1639
  }
1423
1640
  }
1424
- spinner_default.succeed(`imported ${imported} items`);
1641
+ spinner_default.succeed(`${dryRun ? "would import" : "imported"} ${imported} items`);
1425
1642
  if (skipped) spinner_default.info(`skipped ${skipped} items`);
1426
1643
  spinner_default.stop();
1427
1644
  };
@@ -1441,7 +1658,7 @@ var undo = async () => {
1441
1658
  let undone = 0;
1442
1659
  for (const record of records) {
1443
1660
  renameSync4(record.newPath, record.oldPath);
1444
- spinner_default.succeed(`${Color10.cyan.encoder(record.newPath)} \u2192 ${Color10.blue.encoder(record.oldPath)}`);
1661
+ spinner_default.succeed(`${Color10.green.encoder(record.newPath)} \u2192 ${Color10.white.encoder(record.oldPath)}`);
1445
1662
  undone++;
1446
1663
  }
1447
1664
  deleteSession(records[0].sessionId);
@@ -1476,6 +1693,86 @@ var findVideo2 = (dir) => readdirSync8(dir).find((f) => {
1476
1693
  const ext = f.match(/([^.]+$)/)?.[0];
1477
1694
  return ext && videoExtensions_default.includes(ext);
1478
1695
  }) ?? null;
1696
+ var containsBook2 = (dir, depth = 2) => readdirSync8(dir).some((f) => {
1697
+ const ext = f.match(/([^.]+$)/)?.[0];
1698
+ if (ext && bookExtensions_default.includes(ext)) return true;
1699
+ if (depth > 1) {
1700
+ try {
1701
+ const sub = resolve9(dir, f);
1702
+ if (lstatSync5(sub).isDirectory()) return containsBook2(sub, depth - 1);
1703
+ } catch {
1704
+ }
1705
+ }
1706
+ return false;
1707
+ });
1708
+ var isTvEpisodeName2 = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
1709
+ var isSeasonDirName2 = (name) => !isTvEpisodeName2(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
1710
+ var expandWatchPath = (p) => {
1711
+ let isDir;
1712
+ try {
1713
+ isDir = lstatSync5(p).isDirectory();
1714
+ } catch {
1715
+ return [p];
1716
+ }
1717
+ if (!isDir) return [p];
1718
+ const name = basename3(p);
1719
+ if (isTvEpisodeName2(name) || /(?<=\[).+?(?=\])/.test(name) || /^[A-Z]{4}\d{5}/i.test(name)) return [p];
1720
+ let children;
1721
+ try {
1722
+ children = readdirSync8(p);
1723
+ } catch {
1724
+ return [p];
1725
+ }
1726
+ if (children.some((c) => isTvEpisodeName2(c))) {
1727
+ const entries = [];
1728
+ for (const child of children) {
1729
+ const cp = resolve9(p, child);
1730
+ let cd;
1731
+ try {
1732
+ cd = lstatSync5(cp).isDirectory();
1733
+ } catch {
1734
+ continue;
1735
+ }
1736
+ const ext = child.match(/([^.]+$)/)?.[0];
1737
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
1738
+ entries.push(cp);
1739
+ }
1740
+ return entries.length > 0 ? entries : [p];
1741
+ }
1742
+ const seasonDirs = children.filter((c) => {
1743
+ try {
1744
+ return isSeasonDirName2(c) && lstatSync5(resolve9(p, c)).isDirectory();
1745
+ } catch {
1746
+ return false;
1747
+ }
1748
+ });
1749
+ if (seasonDirs.length > 0) {
1750
+ const entries = [];
1751
+ for (const sd of seasonDirs) {
1752
+ const sp = resolve9(p, sd);
1753
+ let sc;
1754
+ try {
1755
+ sc = readdirSync8(sp);
1756
+ } catch {
1757
+ continue;
1758
+ }
1759
+ for (const child of sc) {
1760
+ const cp = resolve9(sp, child);
1761
+ let cd;
1762
+ try {
1763
+ cd = lstatSync5(cp).isDirectory();
1764
+ } catch {
1765
+ continue;
1766
+ }
1767
+ const ext = child.match(/([^.]+$)/)?.[0];
1768
+ if (!cd && !(ext && videoExtensions_default.includes(ext))) continue;
1769
+ entries.push(cp);
1770
+ }
1771
+ }
1772
+ return entries.length > 0 ? entries : [p];
1773
+ }
1774
+ return [p];
1775
+ };
1479
1776
  var findSeasonFolder2 = (showPath, season) => {
1480
1777
  if (!existsSync10(showPath)) return null;
1481
1778
  const folders = readdirSync8(showPath).filter((f) => {
@@ -1499,9 +1796,13 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1499
1796
  const isDir = lstatSync5(entryPath).isDirectory();
1500
1797
  const ext = entry.match(/([^.]+$)/)?.[0];
1501
1798
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1502
- if (!isDir && !isVideo) return;
1799
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1800
+ const isBookDir = isDir && containsBook2(entryPath);
1801
+ if (!isDir && !isVideo && !isBook) return;
1503
1802
  let detectedType;
1504
- if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1803
+ if (isBook || isBookDir) {
1804
+ detectedType = "book";
1805
+ } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1505
1806
  detectedType = "ps3";
1506
1807
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1507
1808
  detectedType = "tv";
@@ -1525,7 +1826,28 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1525
1826
  }
1526
1827
  moveItem(entryPath, destPath);
1527
1828
  recordImport(sessionId, entryPath, destPath, "move");
1528
- spinner_default.succeed(`imported ${Color11.cyan.encoder(destName)}`);
1829
+ spinner_default.succeed(`imported ${Color11.green.encoder(destName)}`);
1830
+ return;
1831
+ }
1832
+ if (detectedType === "book") {
1833
+ const destPath = resolve9(destRoot, entry);
1834
+ if (existsSync10(destPath)) {
1835
+ spinner_default.warn(`already exists: ${entry}`);
1836
+ return;
1837
+ }
1838
+ if (isDir || isBookDir) {
1839
+ moveItem(entryPath, destPath);
1840
+ } else {
1841
+ mkdirSync4(destRoot, { recursive: true });
1842
+ if (sameDev2(entryPath, destRoot)) {
1843
+ renameSync5(entryPath, destPath);
1844
+ } else {
1845
+ cpSync2(entryPath, destPath);
1846
+ rmSync3(entryPath);
1847
+ }
1848
+ }
1849
+ recordImport(sessionId, entryPath, destPath, "move");
1850
+ spinner_default.succeed(`imported ${Color11.green.encoder(entry)}`);
1529
1851
  return;
1530
1852
  }
1531
1853
  const parsed = parseDownloadName(entry);
@@ -1598,7 +1920,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1598
1920
  if (isDir) rmSync3(entryPath, { recursive: true, force: true });
1599
1921
  }
1600
1922
  recordImport(sessionId, entryPath, seasonPath, mode);
1601
- spinner_default.succeed(`imported ${Color11.cyan.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1923
+ spinner_default.succeed(`imported ${Color11.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1602
1924
  return;
1603
1925
  }
1604
1926
  const edition = detectEdition(entry);
@@ -1655,7 +1977,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1655
1977
  }
1656
1978
  recordImport(sessionId, entryPath, destFolder, "move");
1657
1979
  }
1658
- spinner_default.succeed(`imported ${Color11.cyan.encoder(folderName)}`);
1980
+ spinner_default.succeed(`imported ${Color11.green.encoder(folderName)}`);
1659
1981
  };
1660
1982
  var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1661
1983
  const config = getConfig();
@@ -1670,7 +1992,9 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1670
1992
  setTimeout(async () => {
1671
1993
  pending.delete(path);
1672
1994
  try {
1673
- await processItem(path, hardlink, verbose, language, auto);
1995
+ for (const entry of expandWatchPath(path)) {
1996
+ await processItem(entry, hardlink, verbose, language, auto);
1997
+ }
1674
1998
  } catch (err) {
1675
1999
  spinner_default.fail(`error processing ${path}: ${err.message}`);
1676
2000
  }
@@ -1686,7 +2010,7 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1686
2010
  watcher.on("add", handle);
1687
2011
  spinner_default.start();
1688
2012
  spinner_default.succeed(`watching ${config.sources.length} source${config.sources.length !== 1 ? "s" : ""}`);
1689
- for (const s of config.sources) spinner_default.info(` ${Color11.blue.encoder(s)}`);
2013
+ for (const s of config.sources) spinner_default.info(` ${Color11.white.encoder(s)}`);
1690
2014
  spinner_default.stop();
1691
2015
  process.stdin.resume();
1692
2016
  };