reelsort 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,10 @@ 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) => readdirSync7(dir).some((f) => {
1172
+ const ext = f.match(/([^.]+$)/)?.[0];
1173
+ return ext && bookExtensions_default.includes(ext);
1174
+ });
1169
1175
  var findSeasonFolder = (showPath, season) => {
1170
1176
  if (!existsSync9(showPath)) return null;
1171
1177
  const folders = readdirSync7(showPath).filter((f) => {
@@ -1180,27 +1186,128 @@ var findSeasonFolder = (showPath, season) => {
1180
1186
  return match && parseInt(match[1]) === season;
1181
1187
  }) ?? null;
1182
1188
  };
1183
- var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1189
+ var classifyMovieConfidence = (entry) => {
1190
+ if (/^\[(?:Team|Group)\s/i.test(entry)) return "skip";
1191
+ if (/\bepisodes?\s+\d+[-–]\d+/i.test(entry)) return "skip";
1192
+ if (/\b(?:patch|keygen|crack)\b|\bkeys?\s*\{/i.test(entry)) return "skip";
1193
+ if (/\[YTS[.\-]/i.test(entry)) return "auto";
1194
+ if (/\(\d{4}\)/.test(entry) && /\[(?:2160p|1080p|720p|480p|576p|BluRay|BDRip|BDRemux|WEBRip|WEB-DL|HDRip|DVDRip|HDTV)/i.test(entry)) return "auto";
1195
+ if (/\.\d{4}\..*?(?:2160p|1080p|720p|BluRay|WEBRip|WEB-DL|HDTV)/i.test(entry)) return "auto";
1196
+ return "ambiguous";
1197
+ };
1198
+ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto, force, interactive }) => {
1184
1199
  const config = getConfig();
1185
1200
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1186
1201
  const language = config.language ?? "eng";
1187
1202
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1188
1203
  const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
1204
+ const lookupMovie = async (parsed) => {
1205
+ let tmdbId;
1206
+ let resolvedTitle = parsed.title;
1207
+ let resolvedYear = parsed.year;
1208
+ if (config.tmdbApiKey) {
1209
+ const results = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1210
+ if (results.length === 1) {
1211
+ tmdbId = results[0].id;
1212
+ resolvedTitle = results[0].title;
1213
+ resolvedYear = results[0].year ?? parsed.year;
1214
+ } else if (results.length > 1) {
1215
+ spinner_default.stop();
1216
+ const select = new Select();
1217
+ const items = results.map((r) => ({
1218
+ label: r.year ? `${r.title} (${r.year})` : r.title,
1219
+ description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
1220
+ ...r
1221
+ }));
1222
+ const picked = await select.ask(`Multiple movies found for "${parsed.title}":`, items);
1223
+ spinner_default.start();
1224
+ if (picked) {
1225
+ tmdbId = picked.id;
1226
+ resolvedTitle = picked.title;
1227
+ resolvedYear = picked.year ?? parsed.year;
1228
+ }
1229
+ }
1230
+ }
1231
+ return { tmdbId, resolvedTitle, resolvedYear };
1232
+ };
1233
+ const importMovie = async (entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot) => {
1234
+ const edition = detectEdition(entry);
1235
+ const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1236
+ const destFolder = resolve8(destRoot, folderName);
1237
+ if (existsSync9(destFolder)) {
1238
+ spinner_default.warn(`already exists: ${folderName}`);
1239
+ return false;
1240
+ }
1241
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1242
+ if (!videoFile) {
1243
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1244
+ return false;
1245
+ }
1246
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1247
+ const destVideoName = `${folderName}.${videoExt}`;
1248
+ const videoSourcePath = isDir ? resolve8(entryPath, videoFile) : entryPath;
1249
+ const dirFiles = isDir ? readdirSync7(entryPath) : [];
1250
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1251
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1252
+ const subtitleSourcePath = subtitle ? resolve8(entryPath, subtitle) : null;
1253
+ const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1254
+ if (!dryRun) {
1255
+ if (useHardlink) {
1256
+ mkdirSync3(destFolder, { recursive: true });
1257
+ const destVideoPath = resolve8(destFolder, destVideoName);
1258
+ let mode;
1259
+ try {
1260
+ if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1261
+ linkSync(videoSourcePath, destVideoPath);
1262
+ mode = "hardlink";
1263
+ } catch {
1264
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1265
+ cpSync(videoSourcePath, destVideoPath);
1266
+ mode = "copy";
1267
+ }
1268
+ if (subtitleSourcePath && destSubtitleName) cpSync(subtitleSourcePath, resolve8(destFolder, destSubtitleName));
1269
+ recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1270
+ } else {
1271
+ if (isDir) {
1272
+ const keep = new Set([videoFile, subtitle].filter(Boolean));
1273
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) rmSync2(resolve8(entryPath, f), { recursive: true, force: true });
1274
+ renameSync3(videoSourcePath, resolve8(entryPath, destVideoName));
1275
+ if (subtitleSourcePath && destSubtitleName) renameSync3(subtitleSourcePath, resolve8(entryPath, destSubtitleName));
1276
+ moveFolder(entryPath, destFolder);
1277
+ } else {
1278
+ mkdirSync3(destFolder, { recursive: true });
1279
+ const destVideoPath = resolve8(destFolder, destVideoName);
1280
+ if (sameDev(videoSourcePath, destRoot)) {
1281
+ renameSync3(videoSourcePath, destVideoPath);
1282
+ } else {
1283
+ cpSync(videoSourcePath, destVideoPath);
1284
+ rmSync2(videoSourcePath);
1285
+ }
1286
+ }
1287
+ recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1288
+ }
1289
+ }
1290
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1291
+ return true;
1292
+ };
1189
1293
  spinner_default.start();
1190
1294
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1191
1295
  let imported = 0, skipped = 0;
1296
+ const pendingMovies = [];
1192
1297
  for (const source of config.sources) {
1193
1298
  if (!existsSync9(source)) {
1194
- spinner_default.warn(`source not found: ${Color9.blue.encoder(source)}`);
1299
+ spinner_default.warn(`source not found: ${Color9.white.encoder(source)}`);
1195
1300
  continue;
1196
1301
  }
1197
- spinner_default.text = `scanning ${Color9.blue.encoder(source)}`;
1302
+ spinner_default.text = `scanning ${Color9.white.encoder(source)}`;
1198
1303
  for (const entry of readdirSync7(source)) {
1199
1304
  const entryPath = resolve8(source, entry);
1200
1305
  const isDir = lstatSync4(entryPath).isDirectory();
1201
1306
  const ext = entry.match(/([^.]+$)/)?.[0];
1202
1307
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1203
- if (!isDir && !isVideo) {
1308
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1309
+ const isBookDir = isDir && containsBook(entryPath);
1310
+ if (!isDir && !isVideo && !isBook) {
1204
1311
  if (verbose) spinner_default.info(`skipped ${entry}`);
1205
1312
  skipped++;
1206
1313
  continue;
@@ -1208,6 +1315,8 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1208
1315
  let detectedType;
1209
1316
  if (type) {
1210
1317
  detectedType = type;
1318
+ } else if (isBook || isBookDir) {
1319
+ detectedType = "book";
1211
1320
  } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1212
1321
  detectedType = "ps3";
1213
1322
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
@@ -1243,12 +1352,49 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1243
1352
  imported++;
1244
1353
  continue;
1245
1354
  }
1355
+ if (detectedType === "book") {
1356
+ const destPath = resolve8(destRoot, entry);
1357
+ if (existsSync9(destPath)) {
1358
+ spinner_default.warn(`already exists: ${entry}`);
1359
+ skipped++;
1360
+ continue;
1361
+ }
1362
+ if (!dryRun) {
1363
+ if (isDir || isBookDir) {
1364
+ moveFolder(entryPath, destPath);
1365
+ } else {
1366
+ mkdirSync3(destRoot, { recursive: true });
1367
+ if (sameDev(entryPath, destRoot)) {
1368
+ renameSync3(entryPath, destPath);
1369
+ } else {
1370
+ cpSync(entryPath, destPath);
1371
+ rmSync2(entryPath);
1372
+ }
1373
+ }
1374
+ recordImport(sessionId, entryPath, destPath, "move");
1375
+ }
1376
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${entry}`);
1377
+ imported++;
1378
+ continue;
1379
+ }
1246
1380
  const parsed = parseDownloadName(entry);
1247
1381
  if (!parsed) {
1248
1382
  if (verbose) spinner_default.info(`could not parse: ${entry}`);
1249
1383
  skipped++;
1250
1384
  continue;
1251
1385
  }
1386
+ if (detectedType === "movie") {
1387
+ const confidence = classifyMovieConfidence(entry);
1388
+ if (confidence === "skip") {
1389
+ if (verbose) spinner_default.info(`skipped (uncertain): ${entry}`);
1390
+ skipped++;
1391
+ continue;
1392
+ }
1393
+ if (confidence === "ambiguous") {
1394
+ pendingMovies.push({ entry, entryPath, isDir, parsed, destRoot });
1395
+ continue;
1396
+ }
1397
+ }
1252
1398
  let tmdbId;
1253
1399
  let resolvedTitle = parsed.title;
1254
1400
  let resolvedYear = parsed.year;
@@ -1276,12 +1422,10 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1276
1422
  }
1277
1423
  }
1278
1424
  } 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
- }
1425
+ const result = await lookupMovie(parsed);
1426
+ tmdbId = result.tmdbId;
1427
+ resolvedTitle = result.resolvedTitle;
1428
+ resolvedYear = result.resolvedYear;
1285
1429
  }
1286
1430
  }
1287
1431
  if (detectedType === "tv") {
@@ -1307,50 +1451,68 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1307
1451
  }
1308
1452
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1309
1453
  const seasonPath = resolve8(showPath, seasonFolderName);
1310
- const videoFile2 = isDir ? findVideo(entryPath) : entry;
1311
- if (!videoFile2) {
1454
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1455
+ if (!videoFile) {
1312
1456
  if (verbose) spinner_default.info(`no video found in: ${entry}`);
1313
1457
  skipped++;
1314
1458
  continue;
1315
1459
  }
1316
- const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
1460
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1317
1461
  const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1318
1462
  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;
1463
+ const destVideoName = `${episodeName}.${videoExt}`;
1464
+ const destVideoPath = resolve8(seasonPath, destVideoName);
1465
+ const videoSourcePath = isDir ? resolve8(entryPath, videoFile) : entryPath;
1322
1466
  if (existsSync9(destVideoPath)) {
1323
- spinner_default.warn(`already exists: ${episodeName}`);
1324
- skipped++;
1325
- continue;
1467
+ let shouldReplace = force;
1468
+ if (!shouldReplace && interactive) {
1469
+ spinner_default.stop();
1470
+ const select = new Select();
1471
+ const picked = await select.ask(`Already exists \u2014 replace?`, [
1472
+ { label: `${showFolderName} / ${seasonFolderName} / ${episodeName}`, value: "replace" },
1473
+ { label: "Skip", value: "skip" }
1474
+ ]);
1475
+ spinner_default.start();
1476
+ shouldReplace = picked?.value === "replace";
1477
+ }
1478
+ if (!shouldReplace) {
1479
+ spinner_default.warn(`already exists: ${episodeName}`);
1480
+ skipped++;
1481
+ continue;
1482
+ }
1483
+ if (!dryRun) {
1484
+ for (const f of readdirSync7(seasonPath)) {
1485
+ if (f.startsWith(`${episodeName}.`)) rmSync2(resolve8(seasonPath, f));
1486
+ }
1487
+ }
1326
1488
  }
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;
1489
+ const dirFiles = isDir ? readdirSync7(entryPath) : [];
1490
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1491
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1492
+ const subtitleSourcePath = subtitle ? resolve8(entryPath, subtitle) : null;
1493
+ const destSubtitleName = subtitle && subtitleExt ? `${episodeName}.${subtitleExt}` : null;
1332
1494
  if (!dryRun) {
1333
1495
  mkdirSync3(seasonPath, { recursive: true });
1334
1496
  let mode = "move";
1335
1497
  if (useHardlink) {
1336
1498
  try {
1337
- if (!sameDev(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1338
- linkSync(videoSourcePath2, destVideoPath);
1499
+ if (!sameDev(videoSourcePath, seasonPath)) throw new Error("cross-filesystem");
1500
+ linkSync(videoSourcePath, destVideoPath);
1339
1501
  mode = "hardlink";
1340
1502
  } catch {
1341
1503
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1342
- cpSync(videoSourcePath2, destVideoPath);
1504
+ cpSync(videoSourcePath, destVideoPath);
1343
1505
  mode = "copy";
1344
1506
  }
1345
- if (subtitleSourcePath2 && destSubtitleName2) cpSync(subtitleSourcePath2, resolve8(seasonPath, destSubtitleName2));
1507
+ if (subtitleSourcePath && destSubtitleName) cpSync(subtitleSourcePath, resolve8(seasonPath, destSubtitleName));
1346
1508
  } else {
1347
- if (sameDev(videoSourcePath2, seasonPath)) {
1348
- renameSync3(videoSourcePath2, destVideoPath);
1509
+ if (sameDev(videoSourcePath, seasonPath)) {
1510
+ renameSync3(videoSourcePath, destVideoPath);
1349
1511
  } else {
1350
- cpSync(videoSourcePath2, destVideoPath);
1351
- rmSync2(videoSourcePath2);
1512
+ cpSync(videoSourcePath, destVideoPath);
1513
+ rmSync2(videoSourcePath);
1352
1514
  }
1353
- if (subtitleSourcePath2 && destSubtitleName2) renameSync3(subtitleSourcePath2, resolve8(seasonPath, destSubtitleName2));
1515
+ if (subtitleSourcePath && destSubtitleName) renameSync3(subtitleSourcePath, resolve8(seasonPath, destSubtitleName));
1354
1516
  if (isDir) rmSync2(entryPath, { recursive: true, force: true });
1355
1517
  }
1356
1518
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
@@ -1359,66 +1521,40 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1359
1521
  imported++;
1360
1522
  continue;
1361
1523
  }
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}`);
1524
+ if (await importMovie(entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot)) {
1525
+ imported++;
1526
+ } else {
1367
1527
  skipped++;
1368
- continue;
1369
1528
  }
1370
- const videoFile = isDir ? findVideo(entryPath) : entry;
1371
- if (!videoFile) {
1372
- if (verbose) spinner_default.info(`no video found in: ${entry}`);
1529
+ }
1530
+ }
1531
+ if (pendingMovies.length > 0) {
1532
+ 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(/\/$/, "")}`);
1534
+ let toProcess = [];
1535
+ if (interactive) {
1536
+ spinner_default.stop();
1537
+ const ms = new MultiSelect({ allowSkip: true, search: true, maxHeight: 20 });
1538
+ const items = pendingMovies.map((p) => ({
1539
+ label: p.entry.replace(/\/$/, ""),
1540
+ description: p.parsed.year ? `parsed: ${p.parsed.title} \xB7 ${p.parsed.year}` : `parsed: ${p.parsed.title}`,
1541
+ ...p
1542
+ }));
1543
+ toProcess = await ms.ask("Select entries to import as movies:", items) ?? [];
1544
+ spinner_default.start();
1545
+ skipped += pendingMovies.length - toProcess.length;
1546
+ } else if (force) {
1547
+ toProcess = pendingMovies;
1548
+ } else {
1549
+ skipped += pendingMovies.length;
1550
+ }
1551
+ for (const p of toProcess) {
1552
+ const { tmdbId, resolvedTitle, resolvedYear } = await lookupMovie(p.parsed);
1553
+ if (await importMovie(p.entry, p.entryPath, p.isDir, resolvedTitle, resolvedYear, tmdbId, p.destRoot)) {
1554
+ imported++;
1555
+ } else {
1373
1556
  skipped++;
1374
- continue;
1375
1557
  }
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
- }
1420
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1421
- imported++;
1422
1558
  }
1423
1559
  }
1424
1560
  spinner_default.succeed(`imported ${imported} items`);
@@ -1441,7 +1577,7 @@ var undo = async () => {
1441
1577
  let undone = 0;
1442
1578
  for (const record of records) {
1443
1579
  renameSync4(record.newPath, record.oldPath);
1444
- spinner_default.succeed(`${Color10.cyan.encoder(record.newPath)} \u2192 ${Color10.blue.encoder(record.oldPath)}`);
1580
+ spinner_default.succeed(`${Color10.green.encoder(record.newPath)} \u2192 ${Color10.white.encoder(record.oldPath)}`);
1445
1581
  undone++;
1446
1582
  }
1447
1583
  deleteSession(records[0].sessionId);
@@ -1476,6 +1612,10 @@ var findVideo2 = (dir) => readdirSync8(dir).find((f) => {
1476
1612
  const ext = f.match(/([^.]+$)/)?.[0];
1477
1613
  return ext && videoExtensions_default.includes(ext);
1478
1614
  }) ?? null;
1615
+ var containsBook2 = (dir) => readdirSync8(dir).some((f) => {
1616
+ const ext = f.match(/([^.]+$)/)?.[0];
1617
+ return ext && bookExtensions_default.includes(ext);
1618
+ });
1479
1619
  var findSeasonFolder2 = (showPath, season) => {
1480
1620
  if (!existsSync10(showPath)) return null;
1481
1621
  const folders = readdirSync8(showPath).filter((f) => {
@@ -1499,9 +1639,13 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1499
1639
  const isDir = lstatSync5(entryPath).isDirectory();
1500
1640
  const ext = entry.match(/([^.]+$)/)?.[0];
1501
1641
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1502
- if (!isDir && !isVideo) return;
1642
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1643
+ const isBookDir = isDir && containsBook2(entryPath);
1644
+ if (!isDir && !isVideo && !isBook) return;
1503
1645
  let detectedType;
1504
- if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1646
+ if (isBook || isBookDir) {
1647
+ detectedType = "book";
1648
+ } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1505
1649
  detectedType = "ps3";
1506
1650
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1507
1651
  detectedType = "tv";
@@ -1525,7 +1669,28 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1525
1669
  }
1526
1670
  moveItem(entryPath, destPath);
1527
1671
  recordImport(sessionId, entryPath, destPath, "move");
1528
- spinner_default.succeed(`imported ${Color11.cyan.encoder(destName)}`);
1672
+ spinner_default.succeed(`imported ${Color11.green.encoder(destName)}`);
1673
+ return;
1674
+ }
1675
+ if (detectedType === "book") {
1676
+ const destPath = resolve9(destRoot, entry);
1677
+ if (existsSync10(destPath)) {
1678
+ spinner_default.warn(`already exists: ${entry}`);
1679
+ return;
1680
+ }
1681
+ if (isDir || isBookDir) {
1682
+ moveItem(entryPath, destPath);
1683
+ } else {
1684
+ mkdirSync4(destRoot, { recursive: true });
1685
+ if (sameDev2(entryPath, destRoot)) {
1686
+ renameSync5(entryPath, destPath);
1687
+ } else {
1688
+ cpSync2(entryPath, destPath);
1689
+ rmSync3(entryPath);
1690
+ }
1691
+ }
1692
+ recordImport(sessionId, entryPath, destPath, "move");
1693
+ spinner_default.succeed(`imported ${Color11.green.encoder(entry)}`);
1529
1694
  return;
1530
1695
  }
1531
1696
  const parsed = parseDownloadName(entry);
@@ -1598,7 +1763,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1598
1763
  if (isDir) rmSync3(entryPath, { recursive: true, force: true });
1599
1764
  }
1600
1765
  recordImport(sessionId, entryPath, seasonPath, mode);
1601
- spinner_default.succeed(`imported ${Color11.cyan.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1766
+ spinner_default.succeed(`imported ${Color11.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1602
1767
  return;
1603
1768
  }
1604
1769
  const edition = detectEdition(entry);
@@ -1655,7 +1820,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1655
1820
  }
1656
1821
  recordImport(sessionId, entryPath, destFolder, "move");
1657
1822
  }
1658
- spinner_default.succeed(`imported ${Color11.cyan.encoder(folderName)}`);
1823
+ spinner_default.succeed(`imported ${Color11.green.encoder(folderName)}`);
1659
1824
  };
1660
1825
  var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1661
1826
  const config = getConfig();
@@ -1686,7 +1851,7 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1686
1851
  watcher.on("add", handle);
1687
1852
  spinner_default.start();
1688
1853
  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)}`);
1854
+ for (const s of config.sources) spinner_default.info(` ${Color11.white.encoder(s)}`);
1690
1855
  spinner_default.stop();
1691
1856
  process.stdin.resume();
1692
1857
  };