reelsort 0.1.2 → 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
@@ -1,6 +1,6 @@
1
1
  // src/actions/clean.ts
2
- import cosmetic from "cosmetic";
3
2
  import { existsSync as existsSync2, rmSync } from "fs";
3
+ import { Color } from "termkit";
4
4
 
5
5
  // src/db.ts
6
6
  import Database from "better-sqlite3";
@@ -131,28 +131,21 @@ var getHistory = (limit = 10) => {
131
131
  };
132
132
 
133
133
  // src/refs/spinner.ts
134
- import { Spinner as TermSpinner } from "termpulse";
134
+ import { Spinner as TermSpinner } from "termkit";
135
135
  var Spinner = class {
136
136
  spinner;
137
- _isSpinning = false;
138
- _text = "";
139
137
  constructor() {
140
138
  this.spinner = new TermSpinner();
141
139
  }
142
140
  get text() {
143
- return this._text;
141
+ return this.spinner.text;
144
142
  }
145
143
  set text(t) {
146
- this._text = t;
147
- if (this._isSpinning) this.spinner.message(t);
148
- }
149
- get isSpinning() {
150
- return this._isSpinning;
144
+ this.spinner.text = t;
151
145
  }
152
146
  start(s) {
153
- if (s) this._text = s;
147
+ if (s) this.spinner.text = s;
154
148
  this.spinner.start();
155
- this._isSpinning = true;
156
149
  return this;
157
150
  }
158
151
  info(s) {
@@ -173,8 +166,6 @@ var Spinner = class {
173
166
  }
174
167
  stop() {
175
168
  this.spinner.stop();
176
- this._isSpinning = false;
177
- process.stdin.resume();
178
169
  return this;
179
170
  }
180
171
  };
@@ -213,17 +204,17 @@ var clean = async ({ dryRun, olderThan }) => {
213
204
  continue;
214
205
  }
215
206
  if (dryRun) {
216
- spinner_default.succeed(`[dry] would remove ${cosmetic.blue.encoder(imp.sourcePath)}`);
207
+ spinner_default.succeed(`[dry] would remove ${Color.white.encoder(imp.sourcePath)}`);
217
208
  cleaned++;
218
209
  continue;
219
210
  }
220
211
  try {
221
212
  rmSync(imp.sourcePath, { recursive: true, force: true });
222
213
  deleteImport(imp.id);
223
- spinner_default.succeed(`removed ${cosmetic.blue.encoder(imp.sourcePath)}`);
214
+ spinner_default.succeed(`removed ${Color.white.encoder(imp.sourcePath)}`);
224
215
  cleaned++;
225
216
  } catch {
226
- spinner_default.warn(`locked or inaccessible, skipped: ${cosmetic.blue.encoder(imp.sourcePath)}`);
217
+ spinner_default.warn(`locked or inaccessible, skipped: ${Color.white.encoder(imp.sourcePath)}`);
227
218
  skipped++;
228
219
  }
229
220
  }
@@ -234,8 +225,8 @@ var clean = async ({ dryRun, olderThan }) => {
234
225
  var clean_default = clean;
235
226
 
236
227
  // src/actions/config.ts
237
- import cosmetic2 from "cosmetic";
238
228
  import { resolve } from "path";
229
+ import { Color as Color2 } from "termkit";
239
230
 
240
231
  // src/config.ts
241
232
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
@@ -282,38 +273,65 @@ var formatMovieName = (template, title, year, edition) => {
282
273
  };
283
274
 
284
275
  // src/actions/config.ts
285
- var DEST_TYPES = ["movie", "tv", "ps3"];
286
- var configAdd = async ({ key, value }) => {
287
- if (key !== "source") throw new Error(`unknown key '${key}', expected: source`);
288
- const dir = resolve(value);
276
+ var DEST_TYPES = ["movie", "tv", "ps3", "book"];
277
+ var sourceAdd = async ({ dir }) => {
278
+ const resolved = resolve(dir);
289
279
  const config = getConfig();
290
- if (config.sources.includes(dir)) {
280
+ if (config.sources.includes(resolved)) {
291
281
  spinner_default.start();
292
- spinner_default.info(`source already configured: ${cosmetic2.blue.encoder(dir)}`);
282
+ spinner_default.info(`source already configured: ${Color2.white.encoder(resolved)}`);
293
283
  spinner_default.stop();
294
284
  return;
295
285
  }
296
- config.sources.push(dir);
286
+ config.sources.push(resolved);
297
287
  saveConfig(config);
298
288
  spinner_default.start();
299
- spinner_default.succeed(`added source: ${cosmetic2.blue.encoder(dir)}`);
289
+ spinner_default.succeed(`added source: ${Color2.white.encoder(resolved)}`);
300
290
  spinner_default.stop();
301
291
  };
302
- var configRemove = async ({ key, value }) => {
303
- if (key !== "source") throw new Error(`unknown key '${key}', expected: source`);
304
- const dir = resolve(value);
292
+ var sourceRemove = async ({ dir }) => {
293
+ const resolved = resolve(dir);
305
294
  const config = getConfig();
306
- const index = config.sources.indexOf(dir);
295
+ const index = config.sources.indexOf(resolved);
307
296
  if (index === -1) {
308
297
  spinner_default.start();
309
- spinner_default.warn(`source not found: ${cosmetic2.blue.encoder(dir)}`);
298
+ spinner_default.warn(`source not found: ${Color2.white.encoder(resolved)}`);
310
299
  spinner_default.stop();
311
300
  return;
312
301
  }
313
302
  config.sources.splice(index, 1);
314
303
  saveConfig(config);
315
304
  spinner_default.start();
316
- spinner_default.succeed(`removed source: ${cosmetic2.blue.encoder(dir)}`);
305
+ spinner_default.succeed(`removed source: ${Color2.white.encoder(resolved)}`);
306
+ spinner_default.stop();
307
+ };
308
+ var destAdd = async ({ type, dir }) => {
309
+ if (!DEST_TYPES.includes(type)) {
310
+ throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
311
+ }
312
+ const resolved = resolve(dir);
313
+ const config = getConfig();
314
+ config.dest[type] = resolved;
315
+ saveConfig(config);
316
+ spinner_default.start();
317
+ spinner_default.succeed(`set ${type} destination: ${Color2.green.encoder(resolved)}`);
318
+ spinner_default.stop();
319
+ };
320
+ var destRemove = async ({ type }) => {
321
+ if (!DEST_TYPES.includes(type)) {
322
+ throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
323
+ }
324
+ const config = getConfig();
325
+ if (!config.dest[type]) {
326
+ spinner_default.start();
327
+ spinner_default.warn(`no ${type} destination configured`);
328
+ spinner_default.stop();
329
+ return;
330
+ }
331
+ delete config.dest[type];
332
+ saveConfig(config);
333
+ spinner_default.start();
334
+ spinner_default.succeed(`removed ${type} destination`);
317
335
  spinner_default.stop();
318
336
  };
319
337
  var configSet = async ({ key, subkey, value }) => {
@@ -322,7 +340,7 @@ var configSet = async ({ key, subkey, value }) => {
322
340
  config.language = subkey;
323
341
  saveConfig(config);
324
342
  spinner_default.start();
325
- spinner_default.succeed(`set subtitle language: ${cosmetic2.cyan.encoder(subkey)}`);
343
+ spinner_default.succeed(`set subtitle language: ${Color2.green.encoder(subkey)}`);
326
344
  spinner_default.stop();
327
345
  return;
328
346
  }
@@ -352,21 +370,11 @@ var configSet = async ({ key, subkey, value }) => {
352
370
  }
353
371
  saveConfig(config);
354
372
  spinner_default.start();
355
- spinner_default.succeed(`set ${subkey} format: ${cosmetic2.cyan.encoder(value ?? subkey)}`);
373
+ spinner_default.succeed(`set ${subkey} format: ${Color2.green.encoder(value ?? subkey)}`);
356
374
  spinner_default.stop();
357
375
  return;
358
376
  }
359
- if (key !== "dest") throw new Error(`unknown key '${key}', expected: dest, language, tmdb-key, format`);
360
- if (!DEST_TYPES.includes(subkey)) {
361
- throw new Error(`unknown type '${subkey}', expected: ${DEST_TYPES.join(", ")}`);
362
- }
363
- if (!value) throw new Error(`missing path for dest ${subkey}`);
364
- const dir = resolve(value);
365
- config.dest[subkey] = dir;
366
- saveConfig(config);
367
- spinner_default.start();
368
- spinner_default.succeed(`set ${subkey} destination: ${cosmetic2.cyan.encoder(dir)}`);
369
- spinner_default.stop();
377
+ throw new Error(`unknown key '${key}', expected: language, tmdb-key, format`);
370
378
  };
371
379
  var configShow = async () => {
372
380
  const config = getConfig();
@@ -374,7 +382,7 @@ var configShow = async () => {
374
382
  if (config.sources.length === 0) {
375
383
  console.log(" (none)");
376
384
  } else {
377
- for (const s of config.sources) console.log(` ${cosmetic2.blue.encoder(s)}`);
385
+ for (const s of config.sources) console.log(` ${Color2.white.encoder(s)}`);
378
386
  }
379
387
  console.log("\nDestinations:");
380
388
  const entries = DEST_TYPES.map((t) => ({ type: t, path: config.dest[t] })).filter((e) => e.path);
@@ -382,26 +390,26 @@ var configShow = async () => {
382
390
  console.log(" (none)");
383
391
  } else {
384
392
  for (const { type, path } of entries) {
385
- console.log(` ${type.padEnd(6)} ${cosmetic2.cyan.encoder(path)}`);
393
+ console.log(` ${type.padEnd(6)} ${Color2.green.encoder(path)}`);
386
394
  }
387
395
  }
388
396
  console.log(`
389
- Subtitle language: ${cosmetic2.cyan.encoder(config.language ?? "eng (default)")}`);
390
- console.log(`TMDb API key: ${config.tmdbApiKey ? cosmetic2.green.encoder("configured") : cosmetic2.red.encoder("not set")}`);
391
- console.log(`Movie format: ${cosmetic2.cyan.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
392
- console.log(`Episode format: ${cosmetic2.cyan.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
393
- console.log(`Season folder: ${cosmetic2.cyan.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
397
+ Subtitle language: ${Color2.green.encoder(config.language ?? "eng (default)")}`);
398
+ console.log(`TMDb API key: ${config.tmdbApiKey ? Color2.green.encoder("configured") : Color2.red.encoder("not set")}`);
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)")}`);
394
402
  console.log();
395
403
  };
396
404
 
397
405
  // src/actions/differences.ts
398
- import cosmetic3 from "cosmetic";
399
406
  import { existsSync as existsSync4, readdirSync } from "fs";
400
407
  import { resolve as resolve2 } from "path";
408
+ import { Color as Color3 } from "termkit";
401
409
  var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
402
410
  let dir1 = rawDir1;
403
411
  let dir2 = rawDir2;
404
- spinner_default.text = `checking differences between ${cosmetic3.blue.encoder(dir1)} and ${cosmetic3.blue.encoder(dir2)}`;
412
+ spinner_default.text = `checking differences between ${Color3.white.encoder(dir1)} and ${Color3.white.encoder(dir2)}`;
405
413
  spinner_default.start();
406
414
  dir1 = resolve2(dir1);
407
415
  dir2 = resolve2(dir2);
@@ -437,18 +445,18 @@ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
437
445
  removed.push(l);
438
446
  }
439
447
  }
440
- spinner_default.succeed(`checked differences between ${cosmetic3.blue.encoder(dir1)} and ${cosmetic3.blue.encoder(dir2)}`);
448
+ spinner_default.succeed(`checked differences between ${Color3.white.encoder(dir1)} and ${Color3.white.encoder(dir2)}`);
441
449
  spinner_default.succeed(`found ${added.length} added files`);
442
450
  spinner_default.succeed(`found ${removed.length} removed files`);
443
451
  spinner_default.stop();
444
- for (const i of added) console.log(`${cosmetic3.green.encoder("added")} ${i}`);
445
- for (const i of removed) console.log(`${cosmetic3.red.encoder("removed")} ${i}`);
452
+ for (const i of added) console.log(`${Color3.green.encoder("added")} ${i}`);
453
+ for (const i of removed) console.log(`${Color3.red.encoder("removed")} ${i}`);
446
454
  };
447
455
  var differences_default = differences;
448
456
 
449
457
  // src/actions/history.ts
450
- import cosmetic4 from "cosmetic";
451
458
  import { basename, extname } from "path";
459
+ import { Color as Color4 } from "termkit";
452
460
  var history = async ({ limit, imports }) => {
453
461
  if (imports) {
454
462
  const sessions = getImportHistory(limit ?? 10);
@@ -460,11 +468,11 @@ var history = async ({ limit, imports }) => {
460
468
  const date = new Date(session.sessionId);
461
469
  const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
462
470
  console.log(`
463
- ${cosmetic4.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
471
+ ${Color4.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
464
472
  for (const r of session.records) {
465
473
  const src = basename(r.sourcePath);
466
- const dest = cosmetic4.cyan.encoder(r.destinationPath);
467
- const mode = r.mode !== "move" ? ` ${cosmetic4.blue.encoder(`[${r.mode}]`)}` : "";
474
+ const dest = Color4.green.encoder(r.destinationPath);
475
+ const mode = r.mode !== "move" ? ` ${Color4.white.encoder(`[${r.mode}]`)}` : "";
468
476
  console.log(` ${src} \u2192 ${dest}${mode}`);
469
477
  }
470
478
  }
@@ -479,11 +487,11 @@ ${cosmetic4.yellow.encoder(label)} (${session.records.length} item${session.rec
479
487
  const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
480
488
  const folders = session.records.filter((r) => extname(r.newPath) === "");
481
489
  console.log(`
482
- ${cosmetic4.yellow.encoder(label)} (${folders.length} item${folders.length !== 1 ? "s" : ""})`);
490
+ ${Color4.yellow.encoder(label)} (${folders.length} item${folders.length !== 1 ? "s" : ""})`);
483
491
  for (const r of folders) {
484
492
  const oldName = basename(r.oldPath);
485
493
  const newName = basename(r.newPath);
486
- console.log(` ${cosmetic4.blue.encoder(oldName)} \u2192 ${cosmetic4.cyan.encoder(newName)}`);
494
+ console.log(` ${Color4.white.encoder(oldName)} \u2192 ${Color4.green.encoder(newName)}`);
487
495
  }
488
496
  }
489
497
  }
@@ -492,9 +500,9 @@ ${cosmetic4.yellow.encoder(label)} (${folders.length} item${folders.length !==
492
500
  var history_default = history;
493
501
 
494
502
  // src/actions/list.ts
495
- import cosmetic5 from "cosmetic";
496
503
  import { existsSync as existsSync5, lstatSync, readdirSync as readdirSync3 } from "fs";
497
504
  import { resolve as resolve4 } from "path";
505
+ import { Color as Color5, Table } from "termkit";
498
506
 
499
507
  // src/helpers/dirSize.ts
500
508
  import { readdirSync as readdirSync2, statSync } from "fs";
@@ -626,7 +634,6 @@ var parseLibraryFolder = (name) => {
626
634
  if (match) return { title: match[1].trim(), year: parseInt(match[2]) };
627
635
  return { title: name };
628
636
  };
629
- var col = (s, width) => s.padEnd(width).substring(0, width);
630
637
  var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter, sort }) => {
631
638
  const config = getConfig();
632
639
  const types = (type ? [type] : DEST_TYPES2).filter((t) => config.dest[t]);
@@ -635,7 +642,7 @@ var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter
635
642
  const destRoot = config.dest[t];
636
643
  if (!existsSync5(destRoot)) {
637
644
  console.log(`
638
- ${t.toUpperCase()} ${cosmetic5.blue.encoder(destRoot)} (not found)`);
645
+ ${t.toUpperCase()} ${Color5.white.encoder(destRoot)} (not found)`);
639
646
  continue;
640
647
  }
641
648
  const folders = readdirSync3(destRoot).filter((f) => {
@@ -665,18 +672,29 @@ ${t.toUpperCase()} ${cosmetic5.blue.encoder(destRoot)} (not found)`);
665
672
  const yearDiff = (b.year ?? 0) - (a.year ?? 0);
666
673
  return yearDiff !== 0 ? yearDiff : a.title.localeCompare(b.title);
667
674
  });
668
- const titleW = Math.min(50, Math.max(10, ...filtered.map((e) => e.title.length)) + 2);
669
- const divider = "\u2500".repeat(titleW + 44);
670
675
  console.log(`
671
- ${cosmetic5.yellow.encoder(t.toUpperCase())} ${cosmetic5.blue.encoder(destRoot)}`);
672
- console.log(divider);
673
- console.log(`${"Title".padEnd(titleW)} ${"Year".padEnd(6)} ${"Res".padEnd(6)} ${"Codec".padEnd(6)} ${"Size".padEnd(10)} Sub`);
674
- console.log(divider);
675
- for (const e of filtered) {
676
- const sub = e.hasSub ? cosmetic5.green.encoder("\u2713") : cosmetic5.red.encoder("\u2717");
677
- console.log(`${col(e.title, titleW)} ${col(e.year?.toString() ?? "\u2014", 6)} ${col(e.resolution ?? "\u2014", 6)} ${col(e.codec ?? "\u2014", 6)} ${col(e.size, 10)} ${sub}`);
678
- }
679
- console.log(divider);
676
+ ${Color5.yellow.encoder(t.toUpperCase())} ${Color5.white.encoder(destRoot)}`);
677
+ new Table(
678
+ filtered.map((e) => ({
679
+ title: e.title,
680
+ year: e.year,
681
+ resolution: e.resolution,
682
+ codec: e.codec,
683
+ size: e.size,
684
+ sub: e.hasSub
685
+ })),
686
+ {
687
+ separator: " ",
688
+ columns: [
689
+ { key: "title", title: "Title" },
690
+ { key: "year", title: "Year", align: "right", value: (v) => v != null ? String(v) : "\u2014" },
691
+ { key: "resolution", title: "Res", value: (v) => v ?? "\u2014" },
692
+ { key: "codec", title: "Codec", value: (v) => v ?? "\u2014" },
693
+ { key: "size", title: "Size" },
694
+ { key: "sub", title: "Sub", value: (v) => v ? Color5.green.encoder("\u2713") : Color5.red.encoder("\u2717") }
695
+ ]
696
+ }
697
+ ).print();
680
698
  console.log(`${filtered.length} of ${entries.length} item${entries.length !== 1 ? "s" : ""}`);
681
699
  }
682
700
  console.log();
@@ -685,9 +703,9 @@ var list_default = list;
685
703
 
686
704
  // src/actions/probe.ts
687
705
  import { spawnSync } from "child_process";
688
- import cosmetic6 from "cosmetic";
689
706
  import { existsSync as existsSync6, lstatSync as lstatSync2, readdirSync as readdirSync4 } from "fs";
690
707
  import { resolve as resolve5 } from "path";
708
+ import { Color as Color6 } from "termkit";
691
709
  var DEST_TYPES3 = ["movie", "tv", "ps3"];
692
710
  var CODEC_MAP2 = {
693
711
  hevc: "x265",
@@ -763,7 +781,7 @@ var probe = async ({ type, force, verbose }) => {
763
781
  for (const t of types) {
764
782
  const destRoot = config.dest[t];
765
783
  if (!existsSync6(destRoot)) continue;
766
- spinner_default.text = `scanning ${cosmetic6.blue.encoder(destRoot)}`;
784
+ spinner_default.text = `scanning ${Color6.white.encoder(destRoot)}`;
767
785
  const files = walkVideoFiles(destRoot);
768
786
  for (const filePath of files) {
769
787
  if (!force && getMediaInfo(filePath)) {
@@ -771,7 +789,7 @@ var probe = async ({ type, force, verbose }) => {
771
789
  skipped++;
772
790
  continue;
773
791
  }
774
- spinner_default.text = `probing ${cosmetic6.blue.encoder(filePath)}`;
792
+ spinner_default.text = `probing ${Color6.white.encoder(filePath)}`;
775
793
  const result = runFfprobe(filePath);
776
794
  if (!result) {
777
795
  if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
@@ -791,10 +809,10 @@ var probe = async ({ type, force, verbose }) => {
791
809
  var probe_default = probe;
792
810
 
793
811
  // src/actions/rename.ts
794
- import cosmetic7 from "cosmetic";
795
812
  import { existsSync as existsSync7, lstatSync as lstatSync3, readdirSync as readdirSync5, renameSync } from "fs";
796
813
  import { resolve as resolve6 } from "path";
797
814
  import { rimraf } from "rimraf";
815
+ import { Color as Color7 } from "termkit";
798
816
 
799
817
  // src/helpers/findSubtitle.ts
800
818
  var LANGUAGE_ALIASES = {
@@ -855,13 +873,13 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
855
873
  const config = getConfig();
856
874
  const language = config.language ?? "eng";
857
875
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
858
- spinner_default.text = `renaming in ${cosmetic7.blue.encoder(dir)}`;
876
+ spinner_default.text = `renaming in ${Color7.white.encoder(dir)}`;
859
877
  spinner_default.start();
860
878
  if (!existsSync7(dir)) throw new Error(`dir ${dir} does not exist`);
861
879
  const list2 = readdirSync5(dir);
862
880
  let renamed = 0, removed = 0, skipped = 0;
863
881
  for (const [index, entry] of list2.entries()) {
864
- spinner_default.text = `renaming in ${cosmetic7.blue.encoder(dir)} ${index + 1}/${list2.length}`;
882
+ spinner_default.text = `renaming in ${Color7.white.encoder(dir)} ${index + 1}/${list2.length}`;
865
883
  if (!lstatSync3(resolve6(dir, entry)).isDirectory()) {
866
884
  if (verbose) spinner_default.info(`skipped ${entry}`);
867
885
  skipped++;
@@ -946,18 +964,18 @@ var rename = async ({ dir: inputDir, type, verbose }) => {
946
964
  spinner_default.succeed(`renamed ${renamed} files`);
947
965
  if (removed) spinner_default.info(`removed ${removed} files`);
948
966
  spinner_default.info(`skipped ${skipped} files`);
949
- spinner_default.succeed(`done in ${cosmetic7.cyan.encoder(dir)}`);
967
+ spinner_default.succeed(`done in ${Color7.green.encoder(dir)}`);
950
968
  spinner_default.stop();
951
969
  };
952
970
  var rename_default = rename;
953
971
 
954
972
  // src/actions/reset.ts
955
- import cosmetic8 from "cosmetic";
956
973
  import { existsSync as existsSync8, readdirSync as readdirSync6, renameSync as renameSync2 } from "fs";
957
974
  import { basename as basename2, dirname, resolve as resolve7, sep } from "path";
975
+ import { Color as Color8 } from "termkit";
958
976
  var reset = async ({ dir: inputDir, double }) => {
959
977
  let dir = inputDir;
960
- spinner_default.text = `resetting episodes in ${cosmetic8.blue.encoder(dir)}`;
978
+ spinner_default.text = `resetting episodes in ${Color8.white.encoder(dir)}`;
961
979
  spinner_default.start();
962
980
  dir = resolve7(dir);
963
981
  if (!existsSync8(dir)) throw new Error(`dir ${dir} does not exist`);
@@ -986,7 +1004,7 @@ var reset = async ({ dir: inputDir, double }) => {
986
1004
  const episodeFormat = getConfig().format?.episode;
987
1005
  let renamed = 0, skipped = other.length;
988
1006
  for (const [index, i] of sublist.entries()) {
989
- spinner_default.text = `resetting episodes in ${cosmetic8.blue.encoder(dir)} ${index}/${list2.length}`;
1007
+ spinner_default.text = `resetting episodes in ${Color8.white.encoder(dir)} ${index}/${list2.length}`;
990
1008
  const ext = i.match(/([^.]+$)/)?.[0];
991
1009
  const episode = double ? index * 2 + 1 : index + 1;
992
1010
  const name = `${formatEpisode(seasonNum, episode, episodeFormat, double, showTitle)}.${ext}`;
@@ -999,16 +1017,15 @@ var reset = async ({ dir: inputDir, double }) => {
999
1017
  }
1000
1018
  spinner_default.succeed(`renamed ${renamed} files`);
1001
1019
  spinner_default.info(`skipped ${skipped} files`);
1002
- spinner_default.succeed(`done in ${cosmetic8.cyan.encoder(dir)}`);
1020
+ spinner_default.succeed(`done in ${Color8.green.encoder(dir)}`);
1003
1021
  spinner_default.stop();
1004
1022
  };
1005
1023
  var reset_default = reset;
1006
1024
 
1007
1025
  // src/actions/scan.ts
1008
- import cosmetic9 from "cosmetic";
1009
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";
1010
1027
  import { dirname as dirname2, resolve as resolve8 } from "path";
1011
- import { Select } from "termpulse";
1028
+ import { Color as Color9, MultiSelect, Select } from "termkit";
1012
1029
 
1013
1030
  // src/helpers/detectEdition.ts
1014
1031
  var EDITIONS = [
@@ -1081,18 +1098,17 @@ var searchMovie = async (title, year, apiKey) => {
1081
1098
  if (year) url.searchParams.set("year", String(year));
1082
1099
  try {
1083
1100
  const res = await fetch(url.toString());
1084
- if (!res.ok) return null;
1101
+ if (!res.ok) return [];
1085
1102
  const data = await res.json();
1086
- const first = data.results[0];
1087
- if (!first) return null;
1088
- return {
1089
- id: first.id,
1090
- title: first.title,
1091
- year: first.release_date ? parseInt(first.release_date.slice(0, 4)) : void 0,
1092
- url: `${TMDB_WEB}/movie/${first.id}`
1093
- };
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
+ }));
1094
1110
  } catch {
1095
- return null;
1111
+ return [];
1096
1112
  }
1097
1113
  };
1098
1114
  var getEpisodeName = async (seriesId, season, episode, apiKey) => {
@@ -1127,6 +1143,9 @@ var searchTv = async (title, apiKey) => {
1127
1143
  }
1128
1144
  };
1129
1145
 
1146
+ // src/refs/bookExtensions.json
1147
+ var bookExtensions_default = ["epub", "mobi", "azw3", "azw"];
1148
+
1130
1149
  // src/actions/scan.ts
1131
1150
  var sameDev = (a, b) => {
1132
1151
  try {
@@ -1149,6 +1168,10 @@ var findVideo = (dir) => readdirSync7(dir).find((f) => {
1149
1168
  const ext = f.match(/([^.]+$)/)?.[0];
1150
1169
  return ext && videoExtensions_default.includes(ext);
1151
1170
  }) ?? null;
1171
+ var containsBook = (dir) => readdirSync7(dir).some((f) => {
1172
+ const ext = f.match(/([^.]+$)/)?.[0];
1173
+ return ext && bookExtensions_default.includes(ext);
1174
+ });
1152
1175
  var findSeasonFolder = (showPath, season) => {
1153
1176
  if (!existsSync9(showPath)) return null;
1154
1177
  const folders = readdirSync7(showPath).filter((f) => {
@@ -1163,27 +1186,128 @@ var findSeasonFolder = (showPath, season) => {
1163
1186
  return match && parseInt(match[1]) === season;
1164
1187
  }) ?? null;
1165
1188
  };
1166
- 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 }) => {
1167
1199
  const config = getConfig();
1168
1200
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1169
1201
  const language = config.language ?? "eng";
1170
1202
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1171
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
+ };
1172
1293
  spinner_default.start();
1173
1294
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1174
1295
  let imported = 0, skipped = 0;
1296
+ const pendingMovies = [];
1175
1297
  for (const source of config.sources) {
1176
1298
  if (!existsSync9(source)) {
1177
- spinner_default.warn(`source not found: ${cosmetic9.blue.encoder(source)}`);
1299
+ spinner_default.warn(`source not found: ${Color9.white.encoder(source)}`);
1178
1300
  continue;
1179
1301
  }
1180
- spinner_default.text = `scanning ${cosmetic9.blue.encoder(source)}`;
1302
+ spinner_default.text = `scanning ${Color9.white.encoder(source)}`;
1181
1303
  for (const entry of readdirSync7(source)) {
1182
1304
  const entryPath = resolve8(source, entry);
1183
1305
  const isDir = lstatSync4(entryPath).isDirectory();
1184
1306
  const ext = entry.match(/([^.]+$)/)?.[0];
1185
1307
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1186
- if (!isDir && !isVideo) {
1308
+ const isBook = !isDir && ext && bookExtensions_default.includes(ext);
1309
+ const isBookDir = isDir && containsBook(entryPath);
1310
+ if (!isDir && !isVideo && !isBook) {
1187
1311
  if (verbose) spinner_default.info(`skipped ${entry}`);
1188
1312
  skipped++;
1189
1313
  continue;
@@ -1191,6 +1315,8 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1191
1315
  let detectedType;
1192
1316
  if (type) {
1193
1317
  detectedType = type;
1318
+ } else if (isBook || isBookDir) {
1319
+ detectedType = "book";
1194
1320
  } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1195
1321
  detectedType = "ps3";
1196
1322
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
@@ -1226,12 +1352,49 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1226
1352
  imported++;
1227
1353
  continue;
1228
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
+ }
1229
1380
  const parsed = parseDownloadName(entry);
1230
1381
  if (!parsed) {
1231
1382
  if (verbose) spinner_default.info(`could not parse: ${entry}`);
1232
1383
  skipped++;
1233
1384
  continue;
1234
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
+ }
1235
1398
  let tmdbId;
1236
1399
  let resolvedTitle = parsed.title;
1237
1400
  let resolvedYear = parsed.year;
@@ -1259,12 +1422,10 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1259
1422
  }
1260
1423
  }
1261
1424
  } else {
1262
- const tmdb = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1263
- if (tmdb) {
1264
- tmdbId = tmdb.id;
1265
- resolvedTitle = tmdb.title;
1266
- resolvedYear = tmdb.year ?? parsed.year;
1267
- }
1425
+ const result = await lookupMovie(parsed);
1426
+ tmdbId = result.tmdbId;
1427
+ resolvedTitle = result.resolvedTitle;
1428
+ resolvedYear = result.resolvedYear;
1268
1429
  }
1269
1430
  }
1270
1431
  if (detectedType === "tv") {
@@ -1290,50 +1451,68 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1290
1451
  }
1291
1452
  const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1292
1453
  const seasonPath = resolve8(showPath, seasonFolderName);
1293
- const videoFile2 = isDir ? findVideo(entryPath) : entry;
1294
- if (!videoFile2) {
1454
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1455
+ if (!videoFile) {
1295
1456
  if (verbose) spinner_default.info(`no video found in: ${entry}`);
1296
1457
  skipped++;
1297
1458
  continue;
1298
1459
  }
1299
- const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
1460
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1300
1461
  const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1301
1462
  const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, resolvedTitle, tmdbEpisodeName ?? void 0);
1302
- const destVideoName2 = `${episodeName}.${videoExt2}`;
1303
- const destVideoPath = resolve8(seasonPath, destVideoName2);
1304
- 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;
1305
1466
  if (existsSync9(destVideoPath)) {
1306
- spinner_default.warn(`already exists: ${episodeName}`);
1307
- skipped++;
1308
- 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
+ }
1309
1488
  }
1310
- const dirFiles2 = isDir ? readdirSync7(entryPath) : [];
1311
- const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
1312
- const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
1313
- const subtitleSourcePath2 = subtitle2 ? resolve8(entryPath, subtitle2) : null;
1314
- 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;
1315
1494
  if (!dryRun) {
1316
1495
  mkdirSync3(seasonPath, { recursive: true });
1317
1496
  let mode = "move";
1318
1497
  if (useHardlink) {
1319
1498
  try {
1320
- if (!sameDev(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1321
- linkSync(videoSourcePath2, destVideoPath);
1499
+ if (!sameDev(videoSourcePath, seasonPath)) throw new Error("cross-filesystem");
1500
+ linkSync(videoSourcePath, destVideoPath);
1322
1501
  mode = "hardlink";
1323
1502
  } catch {
1324
1503
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1325
- cpSync(videoSourcePath2, destVideoPath);
1504
+ cpSync(videoSourcePath, destVideoPath);
1326
1505
  mode = "copy";
1327
1506
  }
1328
- if (subtitleSourcePath2 && destSubtitleName2) cpSync(subtitleSourcePath2, resolve8(seasonPath, destSubtitleName2));
1507
+ if (subtitleSourcePath && destSubtitleName) cpSync(subtitleSourcePath, resolve8(seasonPath, destSubtitleName));
1329
1508
  } else {
1330
- if (sameDev(videoSourcePath2, seasonPath)) {
1331
- renameSync3(videoSourcePath2, destVideoPath);
1509
+ if (sameDev(videoSourcePath, seasonPath)) {
1510
+ renameSync3(videoSourcePath, destVideoPath);
1332
1511
  } else {
1333
- cpSync(videoSourcePath2, destVideoPath);
1334
- rmSync2(videoSourcePath2);
1512
+ cpSync(videoSourcePath, destVideoPath);
1513
+ rmSync2(videoSourcePath);
1335
1514
  }
1336
- if (subtitleSourcePath2 && destSubtitleName2) renameSync3(subtitleSourcePath2, resolve8(seasonPath, destSubtitleName2));
1515
+ if (subtitleSourcePath && destSubtitleName) renameSync3(subtitleSourcePath, resolve8(seasonPath, destSubtitleName));
1337
1516
  if (isDir) rmSync2(entryPath, { recursive: true, force: true });
1338
1517
  }
1339
1518
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
@@ -1342,66 +1521,40 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1342
1521
  imported++;
1343
1522
  continue;
1344
1523
  }
1345
- const edition = detectEdition(entry);
1346
- const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1347
- const destFolder = resolve8(destRoot, folderName);
1348
- if (existsSync9(destFolder)) {
1349
- spinner_default.warn(`already exists: ${folderName}`);
1524
+ if (await importMovie(entry, entryPath, isDir, resolvedTitle, resolvedYear, tmdbId, destRoot)) {
1525
+ imported++;
1526
+ } else {
1350
1527
  skipped++;
1351
- continue;
1352
1528
  }
1353
- const videoFile = isDir ? findVideo(entryPath) : entry;
1354
- if (!videoFile) {
1355
- 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 {
1356
1556
  skipped++;
1357
- continue;
1358
- }
1359
- const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1360
- const destVideoName = `${folderName}.${videoExt}`;
1361
- const videoSourcePath = isDir ? resolve8(entryPath, videoFile) : entryPath;
1362
- const dirFiles = isDir ? readdirSync7(entryPath) : [];
1363
- const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1364
- const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1365
- const subtitleSourcePath = subtitle ? resolve8(entryPath, subtitle) : null;
1366
- const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1367
- if (!dryRun) {
1368
- if (useHardlink) {
1369
- mkdirSync3(destFolder, { recursive: true });
1370
- const destVideoPath = resolve8(destFolder, destVideoName);
1371
- let mode;
1372
- try {
1373
- if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1374
- linkSync(videoSourcePath, destVideoPath);
1375
- mode = "hardlink";
1376
- } catch {
1377
- spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1378
- cpSync(videoSourcePath, destVideoPath);
1379
- mode = "copy";
1380
- }
1381
- if (subtitleSourcePath && destSubtitleName) cpSync(subtitleSourcePath, resolve8(destFolder, destSubtitleName));
1382
- recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1383
- } else {
1384
- if (isDir) {
1385
- const keep = new Set([videoFile, subtitle].filter(Boolean));
1386
- for (const f of dirFiles.filter((f2) => !keep.has(f2))) rmSync2(resolve8(entryPath, f), { recursive: true, force: true });
1387
- renameSync3(videoSourcePath, resolve8(entryPath, destVideoName));
1388
- if (subtitleSourcePath && destSubtitleName) renameSync3(subtitleSourcePath, resolve8(entryPath, destSubtitleName));
1389
- moveFolder(entryPath, destFolder);
1390
- } else {
1391
- mkdirSync3(destFolder, { recursive: true });
1392
- const destVideoPath = resolve8(destFolder, destVideoName);
1393
- if (sameDev(videoSourcePath, destRoot)) {
1394
- renameSync3(videoSourcePath, destVideoPath);
1395
- } else {
1396
- cpSync(videoSourcePath, destVideoPath);
1397
- rmSync2(videoSourcePath);
1398
- }
1399
- }
1400
- recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1401
- }
1402
1557
  }
1403
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1404
- imported++;
1405
1558
  }
1406
1559
  }
1407
1560
  spinner_default.succeed(`imported ${imported} items`);
@@ -1411,8 +1564,8 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1411
1564
  var scan_default = scan;
1412
1565
 
1413
1566
  // src/actions/undo.ts
1414
- import cosmetic10 from "cosmetic";
1415
1567
  import { renameSync as renameSync4 } from "fs";
1568
+ import { Color as Color10 } from "termkit";
1416
1569
  var undo = async () => {
1417
1570
  spinner_default.start();
1418
1571
  const records = getLastSession();
@@ -1424,7 +1577,7 @@ var undo = async () => {
1424
1577
  let undone = 0;
1425
1578
  for (const record of records) {
1426
1579
  renameSync4(record.newPath, record.oldPath);
1427
- spinner_default.succeed(`${cosmetic10.cyan.encoder(record.newPath)} \u2192 ${cosmetic10.blue.encoder(record.oldPath)}`);
1580
+ spinner_default.succeed(`${Color10.green.encoder(record.newPath)} \u2192 ${Color10.white.encoder(record.oldPath)}`);
1428
1581
  undone++;
1429
1582
  }
1430
1583
  deleteSession(records[0].sessionId);
@@ -1435,9 +1588,9 @@ var undo_default = undo;
1435
1588
 
1436
1589
  // src/actions/watch.ts
1437
1590
  import chokidar from "chokidar";
1438
- import cosmetic11 from "cosmetic";
1439
1591
  import { cpSync as cpSync2, existsSync as existsSync10, linkSync as linkSync2, lstatSync as lstatSync5, mkdirSync as mkdirSync4, readdirSync as readdirSync8, renameSync as renameSync5, rmSync as rmSync3, statSync as statSync3 } from "fs";
1440
1592
  import { basename as basename3, dirname as dirname3, resolve as resolve9 } from "path";
1593
+ import { Color as Color11 } from "termkit";
1441
1594
  var sameDev2 = (a, b) => {
1442
1595
  try {
1443
1596
  let bExisting = b;
@@ -1459,6 +1612,10 @@ var findVideo2 = (dir) => readdirSync8(dir).find((f) => {
1459
1612
  const ext = f.match(/([^.]+$)/)?.[0];
1460
1613
  return ext && videoExtensions_default.includes(ext);
1461
1614
  }) ?? null;
1615
+ var containsBook2 = (dir) => readdirSync8(dir).some((f) => {
1616
+ const ext = f.match(/([^.]+$)/)?.[0];
1617
+ return ext && bookExtensions_default.includes(ext);
1618
+ });
1462
1619
  var findSeasonFolder2 = (showPath, season) => {
1463
1620
  if (!existsSync10(showPath)) return null;
1464
1621
  const folders = readdirSync8(showPath).filter((f) => {
@@ -1482,9 +1639,13 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1482
1639
  const isDir = lstatSync5(entryPath).isDirectory();
1483
1640
  const ext = entry.match(/([^.]+$)/)?.[0];
1484
1641
  const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1485
- 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;
1486
1645
  let detectedType;
1487
- if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1646
+ if (isBook || isBookDir) {
1647
+ detectedType = "book";
1648
+ } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1488
1649
  detectedType = "ps3";
1489
1650
  } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1490
1651
  detectedType = "tv";
@@ -1508,7 +1669,28 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1508
1669
  }
1509
1670
  moveItem(entryPath, destPath);
1510
1671
  recordImport(sessionId, entryPath, destPath, "move");
1511
- spinner_default.succeed(`imported ${cosmetic11.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)}`);
1512
1694
  return;
1513
1695
  }
1514
1696
  const parsed = parseDownloadName(entry);
@@ -1581,7 +1763,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1581
1763
  if (isDir) rmSync3(entryPath, { recursive: true, force: true });
1582
1764
  }
1583
1765
  recordImport(sessionId, entryPath, seasonPath, mode);
1584
- spinner_default.succeed(`imported ${cosmetic11.cyan.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1766
+ spinner_default.succeed(`imported ${Color11.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1585
1767
  return;
1586
1768
  }
1587
1769
  const edition = detectEdition(entry);
@@ -1638,7 +1820,7 @@ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1638
1820
  }
1639
1821
  recordImport(sessionId, entryPath, destFolder, "move");
1640
1822
  }
1641
- spinner_default.succeed(`imported ${cosmetic11.cyan.encoder(folderName)}`);
1823
+ spinner_default.succeed(`imported ${Color11.green.encoder(folderName)}`);
1642
1824
  };
1643
1825
  var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1644
1826
  const config = getConfig();
@@ -1669,7 +1851,7 @@ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1669
1851
  watcher.on("add", handle);
1670
1852
  spinner_default.start();
1671
1853
  spinner_default.succeed(`watching ${config.sources.length} source${config.sources.length !== 1 ? "s" : ""}`);
1672
- for (const s of config.sources) spinner_default.info(` ${cosmetic11.blue.encoder(s)}`);
1854
+ for (const s of config.sources) spinner_default.info(` ${Color11.white.encoder(s)}`);
1673
1855
  spinner_default.stop();
1674
1856
  process.stdin.resume();
1675
1857
  };
@@ -1679,12 +1861,12 @@ export {
1679
1861
  DEFAULT_MOVIE_FORMAT,
1680
1862
  DEFAULT_SEASON_FORMAT,
1681
1863
  clean_default as clean,
1682
- configAdd,
1683
- configRemove,
1684
1864
  configSet,
1685
1865
  configShow,
1686
1866
  deleteImport,
1687
1867
  deleteSession,
1868
+ destAdd,
1869
+ destRemove,
1688
1870
  detectEdition,
1689
1871
  differences_default as differences,
1690
1872
  formatEpisode,
@@ -1709,6 +1891,8 @@ export {
1709
1891
  reset_default as reset,
1710
1892
  saveConfig,
1711
1893
  scan_default as scan,
1894
+ sourceAdd,
1895
+ sourceRemove,
1712
1896
  titleCase_default as titleCase,
1713
1897
  undo_default as undo,
1714
1898
  upsertMediaInfo,