reelsort 0.2.6 → 0.2.8

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.
Files changed (4) hide show
  1. package/dist/cli.js +360 -321
  2. package/dist/index.js +284 -251
  3. package/dist/index.mjs +251 -218
  4. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/actions/clean.ts
2
2
  import { existsSync as existsSync2, rmSync } from "fs";
3
- import { Color } from "termkit";
3
+ import { Color, Spinner } from "termkit";
4
4
 
5
5
  // src/db.ts
6
6
  import Database from "better-sqlite3";
@@ -142,48 +142,8 @@ var getHistory = (limit = 10) => {
142
142
  }));
143
143
  };
144
144
 
145
- // src/refs/spinner.ts
146
- import { Spinner as TermSpinner } from "termkit";
147
- var Spinner = class {
148
- spinner;
149
- constructor() {
150
- this.spinner = new TermSpinner();
151
- }
152
- get text() {
153
- return this.spinner.text;
154
- }
155
- set text(t) {
156
- this.spinner.text = t;
157
- }
158
- start(s) {
159
- if (s) this.spinner.text = s;
160
- this.spinner.start();
161
- return this;
162
- }
163
- info(s) {
164
- this.spinner.info(s);
165
- return this;
166
- }
167
- warn(s) {
168
- this.spinner.warn(s);
169
- return this;
170
- }
171
- fail(s) {
172
- this.spinner.fail(s);
173
- return this;
174
- }
175
- succeed(s) {
176
- this.spinner.succeed(s);
177
- return this;
178
- }
179
- stop() {
180
- this.spinner.stop();
181
- return this;
182
- }
183
- };
184
- var spinner_default = new Spinner();
185
-
186
145
  // src/actions/clean.ts
146
+ var spinner = new Spinner();
187
147
  var parseOlderThan = (s) => {
188
148
  const match = s.match(/^(\d+)([dhm])$/);
189
149
  if (!match) return null;
@@ -193,11 +153,11 @@ var parseOlderThan = (s) => {
193
153
  return ms;
194
154
  };
195
155
  var clean = async ({ dryRun, olderThan }) => {
196
- spinner_default.start();
156
+ spinner.start();
197
157
  const imports = getCleanableImports();
198
158
  if (imports.length === 0) {
199
- spinner_default.info("nothing to clean");
200
- spinner_default.stop();
159
+ spinner.info("nothing to clean");
160
+ spinner.stop();
201
161
  return;
202
162
  }
203
163
  const cutoffMs = olderThan ? parseOlderThan(olderThan) : null;
@@ -216,29 +176,29 @@ var clean = async ({ dryRun, olderThan }) => {
216
176
  continue;
217
177
  }
218
178
  if (dryRun) {
219
- spinner_default.succeed(`[dry] would remove ${Color.white.encoder(imp.sourcePath)}`);
179
+ spinner.succeed(`[dry] would remove ${Color.white.encoder(imp.sourcePath)}`);
220
180
  cleaned++;
221
181
  continue;
222
182
  }
223
183
  try {
224
184
  rmSync(imp.sourcePath, { recursive: true, force: true });
225
185
  deleteImport(imp.id);
226
- spinner_default.succeed(`removed ${Color.white.encoder(imp.sourcePath)}`);
186
+ spinner.succeed(`removed ${Color.white.encoder(imp.sourcePath)}`);
227
187
  cleaned++;
228
188
  } catch {
229
- spinner_default.warn(`locked or inaccessible, skipped: ${Color.white.encoder(imp.sourcePath)}`);
189
+ spinner.warn(`locked or inaccessible, skipped: ${Color.white.encoder(imp.sourcePath)}`);
230
190
  skipped++;
231
191
  }
232
192
  }
233
- spinner_default.succeed(`cleaned ${cleaned} items`);
234
- if (skipped) spinner_default.info(`skipped ${skipped} items`);
235
- spinner_default.stop();
193
+ spinner.succeed(`cleaned ${cleaned} items`);
194
+ if (skipped) spinner.info(`skipped ${skipped} items`);
195
+ spinner.stop();
236
196
  };
237
197
  var clean_default = clean;
238
198
 
239
199
  // src/actions/config.ts
240
200
  import { resolve } from "path";
241
- import { Color as Color2, MultiSelect, Select } from "termkit";
201
+ import { Color as Color2, MultiSelect, Select, Spinner as Spinner2 } from "termkit";
242
202
 
243
203
  // src/config.ts
244
204
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
@@ -285,29 +245,30 @@ var formatMovieName = (template, title, year, edition) => {
285
245
  };
286
246
 
287
247
  // src/actions/config.ts
248
+ var spinner2 = new Spinner2();
288
249
  var DEST_TYPES = ["movie", "tv", "ps3", "book"];
289
250
  var sourceAdd = async ({ dir }) => {
290
251
  const resolved = resolve(dir);
291
252
  const config = getConfig();
292
253
  if (config.sources.includes(resolved)) {
293
- spinner_default.start();
294
- spinner_default.info(`source already configured: ${Color2.white.encoder(resolved)}`);
295
- spinner_default.stop();
254
+ spinner2.start();
255
+ spinner2.info(`source already configured: ${Color2.white.encoder(resolved)}`);
256
+ spinner2.stop();
296
257
  return;
297
258
  }
298
259
  config.sources.push(resolved);
299
260
  saveConfig(config);
300
- spinner_default.start();
301
- spinner_default.succeed(`added source: ${Color2.white.encoder(resolved)}`);
302
- spinner_default.stop();
261
+ spinner2.start();
262
+ spinner2.succeed(`added source: ${Color2.white.encoder(resolved)}`);
263
+ spinner2.stop();
303
264
  };
304
265
  var sourceRemove = async ({ dir }) => {
305
266
  const config = getConfig();
306
267
  if (!dir) {
307
268
  if (config.sources.length === 0) {
308
- spinner_default.start();
309
- spinner_default.warn("no sources configured");
310
- spinner_default.stop();
269
+ spinner2.start();
270
+ spinner2.warn("no sources configured");
271
+ spinner2.stop();
311
272
  return;
312
273
  }
313
274
  const select = new Select();
@@ -318,16 +279,16 @@ var sourceRemove = async ({ dir }) => {
318
279
  const resolved = resolve(dir);
319
280
  const index = config.sources.indexOf(resolved);
320
281
  if (index === -1) {
321
- spinner_default.start();
322
- spinner_default.warn(`source not found: ${Color2.white.encoder(resolved)}`);
323
- spinner_default.stop();
282
+ spinner2.start();
283
+ spinner2.warn(`source not found: ${Color2.white.encoder(resolved)}`);
284
+ spinner2.stop();
324
285
  return;
325
286
  }
326
287
  config.sources.splice(index, 1);
327
288
  saveConfig(config);
328
- spinner_default.start();
329
- spinner_default.succeed(`removed source: ${Color2.white.encoder(resolved)}`);
330
- spinner_default.stop();
289
+ spinner2.start();
290
+ spinner2.succeed(`removed source: ${Color2.white.encoder(resolved)}`);
291
+ spinner2.stop();
331
292
  };
332
293
  var destAdd = async ({ type, dir }) => {
333
294
  if (!DEST_TYPES.includes(type)) {
@@ -337,18 +298,18 @@ var destAdd = async ({ type, dir }) => {
337
298
  const config = getConfig();
338
299
  config.dest[type] = resolved;
339
300
  saveConfig(config);
340
- spinner_default.start();
341
- spinner_default.succeed(`set ${type} destination: ${Color2.green.encoder(resolved)}`);
342
- spinner_default.stop();
301
+ spinner2.start();
302
+ spinner2.succeed(`set ${type} destination: ${Color2.green.encoder(resolved)}`);
303
+ spinner2.stop();
343
304
  };
344
305
  var destRemove = async ({ type }) => {
345
306
  const config = getConfig();
346
307
  if (!type) {
347
308
  const configured = DEST_TYPES.filter((t) => config.dest[t]);
348
309
  if (configured.length === 0) {
349
- spinner_default.start();
350
- spinner_default.warn("no destinations configured");
351
- spinner_default.stop();
310
+ spinner2.start();
311
+ spinner2.warn("no destinations configured");
312
+ spinner2.stop();
352
313
  return;
353
314
  }
354
315
  const select = new Select();
@@ -360,33 +321,33 @@ var destRemove = async ({ type }) => {
360
321
  throw new Error(`unknown type '${type}', expected: ${DEST_TYPES.join(", ")}`);
361
322
  }
362
323
  if (!config.dest[type]) {
363
- spinner_default.start();
364
- spinner_default.warn(`no ${type} destination configured`);
365
- spinner_default.stop();
324
+ spinner2.start();
325
+ spinner2.warn(`no ${type} destination configured`);
326
+ spinner2.stop();
366
327
  return;
367
328
  }
368
329
  delete config.dest[type];
369
330
  saveConfig(config);
370
- spinner_default.start();
371
- spinner_default.succeed(`removed ${type} destination`);
372
- spinner_default.stop();
331
+ spinner2.start();
332
+ spinner2.succeed(`removed ${type} destination`);
333
+ spinner2.stop();
373
334
  };
374
335
  var configSet = async ({ key, subkey, value }) => {
375
336
  const config = getConfig();
376
337
  if (key === "language") {
377
338
  config.language = subkey;
378
339
  saveConfig(config);
379
- spinner_default.start();
380
- spinner_default.succeed(`set subtitle language: ${Color2.green.encoder(subkey)}`);
381
- spinner_default.stop();
340
+ spinner2.start();
341
+ spinner2.succeed(`set subtitle language: ${Color2.green.encoder(subkey)}`);
342
+ spinner2.stop();
382
343
  return;
383
344
  }
384
345
  if (key === "tmdb-key") {
385
346
  config.tmdbApiKey = subkey;
386
347
  saveConfig(config);
387
- spinner_default.start();
388
- spinner_default.succeed(`set TMDb API key`);
389
- spinner_default.stop();
348
+ spinner2.start();
349
+ spinner2.succeed(`set TMDb API key`);
350
+ spinner2.stop();
390
351
  return;
391
352
  }
392
353
  if (key === "format") {
@@ -406,9 +367,9 @@ var configSet = async ({ key, subkey, value }) => {
406
367
  throw new Error(`unknown format key '${subkey}', expected: movie, episode, season`);
407
368
  }
408
369
  saveConfig(config);
409
- spinner_default.start();
410
- spinner_default.succeed(`set ${subkey} format: ${Color2.green.encoder(value ?? subkey)}`);
411
- spinner_default.stop();
370
+ spinner2.start();
371
+ spinner2.succeed(`set ${subkey} format: ${Color2.green.encoder(value ?? subkey)}`);
372
+ spinner2.stop();
412
373
  return;
413
374
  }
414
375
  throw new Error(`unknown key '${key}', expected: language, tmdb-key, format`);
@@ -448,12 +409,13 @@ Subtitle language: ${Color2.green.encoder(config.language ?? "eng (default)")}`)
448
409
  // src/actions/differences.ts
449
410
  import { existsSync as existsSync4, readdirSync } from "fs";
450
411
  import { resolve as resolve2 } from "path";
451
- import { Color as Color3 } from "termkit";
412
+ import { Color as Color3, Spinner as Spinner3 } from "termkit";
413
+ var spinner3 = new Spinner3();
452
414
  var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
453
415
  let dir1 = rawDir1;
454
416
  let dir2 = rawDir2;
455
- spinner_default.text = `checking differences between ${Color3.white.encoder(dir1)} and ${Color3.white.encoder(dir2)}`;
456
- spinner_default.start();
417
+ spinner3.update(`checking differences between ${Color3.white.encoder(dir1)} and ${Color3.white.encoder(dir2)}`);
418
+ spinner3.start();
457
419
  dir1 = resolve2(dir1);
458
420
  dir2 = resolve2(dir2);
459
421
  if (!existsSync4(dir1)) throw new Error(`dir1 ${dir1} does not exist`);
@@ -488,10 +450,10 @@ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
488
450
  removed.push(l);
489
451
  }
490
452
  }
491
- spinner_default.succeed(`checked differences between ${Color3.white.encoder(dir1)} and ${Color3.white.encoder(dir2)}`);
492
- spinner_default.succeed(`found ${added.length} added files`);
493
- spinner_default.succeed(`found ${removed.length} removed files`);
494
- spinner_default.stop();
453
+ spinner3.succeed(`checked differences between ${Color3.white.encoder(dir1)} and ${Color3.white.encoder(dir2)}`);
454
+ spinner3.succeed(`found ${added.length} added files`);
455
+ spinner3.succeed(`found ${removed.length} removed files`);
456
+ spinner3.stop();
495
457
  for (const i of added) console.log(`${Color3.green.encoder("added")} ${i}`);
496
458
  for (const i of removed) console.log(`${Color3.red.encoder("removed")} ${i}`);
497
459
  };
@@ -748,13 +710,14 @@ var list_default = list;
748
710
  import { spawnSync } from "child_process";
749
711
  import { existsSync as existsSync6, lstatSync as lstatSync2, readdirSync as readdirSync4 } from "fs";
750
712
  import { resolve as resolve5 } from "path";
751
- import { Color as Color6 } from "termkit";
713
+ import { Color as Color6, Spinner as Spinner4 } from "termkit";
752
714
 
753
715
  // src/refs/verbose.ts
754
716
  var _verbose = false;
755
717
  var isVerbose = () => _verbose;
756
718
 
757
719
  // src/actions/probe.ts
720
+ var spinner4 = new Spinner4();
758
721
  var DEST_TYPES3 = ["movie", "tv", "ps3"];
759
722
  var CODEC_MAP2 = {
760
723
  hevc: "x265",
@@ -817,10 +780,10 @@ var walkVideoFiles = (dir, depth = 0, maxDepth = 3) => {
817
780
  return results;
818
781
  };
819
782
  var probe = async ({ type, force }) => {
820
- spinner_default.start();
783
+ spinner4.start();
821
784
  if (!isFfprobeAvailable()) {
822
- spinner_default.fail("ffprobe not found \u2014 install ffmpeg to use this command");
823
- spinner_default.stop();
785
+ spinner4.fail("ffprobe not found \u2014 install ffmpeg to use this command");
786
+ spinner4.stop();
824
787
  return;
825
788
  }
826
789
  const config = getConfig();
@@ -830,30 +793,30 @@ var probe = async ({ type, force }) => {
830
793
  for (const t of types) {
831
794
  const destRoot = config.dest[t];
832
795
  if (!existsSync6(destRoot)) continue;
833
- spinner_default.text = `scanning ${Color6.white.encoder(destRoot)}`;
796
+ spinner4.update(`scanning ${Color6.white.encoder(destRoot)}`);
834
797
  const files = walkVideoFiles(destRoot);
835
798
  for (const filePath of files) {
836
799
  if (!force && getMediaInfo(filePath)) {
837
- if (isVerbose()) spinner_default.info(`already probed: ${filePath}`);
800
+ if (isVerbose()) spinner4.info(`already probed: ${filePath}`);
838
801
  skipped++;
839
802
  continue;
840
803
  }
841
- spinner_default.text = `probing ${Color6.white.encoder(filePath)}`;
804
+ spinner4.update(`probing ${Color6.white.encoder(filePath)}`);
842
805
  const result = runFfprobe(filePath);
843
806
  if (!result) {
844
- if (isVerbose()) spinner_default.warn(`ffprobe failed: ${filePath}`);
807
+ if (isVerbose()) spinner4.warn(`ffprobe failed: ${filePath}`);
845
808
  failed++;
846
809
  continue;
847
810
  }
848
811
  upsertMediaInfo(filePath, result.codec, result.resolution, result.width, result.height, result.duration);
849
- if (isVerbose()) spinner_default.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
812
+ if (isVerbose()) spinner4.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
850
813
  probed++;
851
814
  }
852
815
  }
853
- spinner_default.succeed(`probed ${probed} files`);
854
- if (skipped) spinner_default.info(`skipped ${skipped} already indexed`);
855
- if (failed) spinner_default.warn(`failed ${failed} files`);
856
- spinner_default.stop();
816
+ spinner4.succeed(`probed ${probed} files`);
817
+ if (skipped) spinner4.info(`skipped ${skipped} already indexed`);
818
+ if (failed) spinner4.warn(`failed ${failed} files`);
819
+ spinner4.stop();
857
820
  };
858
821
  var probe_default = probe;
859
822
 
@@ -861,7 +824,7 @@ var probe_default = probe;
861
824
  import { existsSync as existsSync7, lstatSync as lstatSync3, readdirSync as readdirSync5, renameSync } from "fs";
862
825
  import { resolve as resolve6 } from "path";
863
826
  import { rimraf } from "rimraf";
864
- import { Color as Color7 } from "termkit";
827
+ import { Color as Color7, Spinner as Spinner5 } from "termkit";
865
828
 
866
829
  // src/helpers/findSubtitle.ts
867
830
  var LANGUAGE_ALIASES = {
@@ -916,21 +879,22 @@ var titleCase_default = (s) => {
916
879
  };
917
880
 
918
881
  // src/actions/rename.ts
882
+ var spinner5 = new Spinner5();
919
883
  var rename = async ({ dir: inputDir, type }) => {
920
884
  const dir = resolve6(inputDir);
921
885
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
922
886
  const config = getConfig();
923
887
  const language = config.language ?? "eng";
924
888
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
925
- spinner_default.text = `renaming in ${Color7.white.encoder(dir)}`;
926
- spinner_default.start();
889
+ spinner5.update(`renaming in ${Color7.white.encoder(dir)}`);
890
+ spinner5.start();
927
891
  if (!existsSync7(dir)) throw new Error(`dir ${dir} does not exist`);
928
892
  const list2 = readdirSync5(dir);
929
893
  let renamed = 0, removed = 0, skipped = 0;
930
894
  for (const [index, entry] of list2.entries()) {
931
- spinner_default.text = `renaming in ${Color7.white.encoder(dir)} ${index + 1}/${list2.length}`;
895
+ spinner5.update(`renaming in ${Color7.white.encoder(dir)} ${index + 1}/${list2.length}`);
932
896
  if (!lstatSync3(resolve6(dir, entry)).isDirectory()) {
933
- if (isVerbose()) spinner_default.info(`skipped ${entry}`);
897
+ if (isVerbose()) spinner5.info(`skipped ${entry}`);
934
898
  skipped++;
935
899
  continue;
936
900
  }
@@ -940,7 +904,7 @@ var rename = async ({ dir: inputDir, type }) => {
940
904
  const nameMatch = entry.match(/(?<=\[).+?(?=\])/);
941
905
  const id = entry.split("-")[0];
942
906
  if (!nameMatch || !id) {
943
- if (isVerbose()) spinner_default.info(`skipped ${entry}`);
907
+ if (isVerbose()) spinner5.info(`skipped ${entry}`);
944
908
  skipped++;
945
909
  continue;
946
910
  }
@@ -948,19 +912,19 @@ var rename = async ({ dir: inputDir, type }) => {
948
912
  const ps3New = resolve6(dir, `${nameMatch[0]} [${id}]`);
949
913
  renameSync(ps3Old, ps3New);
950
914
  recordRename(sessionId, ps3Old, ps3New);
951
- spinner_default.succeed(`${nameMatch[0]} [${id}]`);
915
+ spinner5.succeed(`${nameMatch[0]} [${id}]`);
952
916
  renamed++;
953
917
  continue;
954
918
  }
955
919
  const yearMatch = entry.match(/\([^\d]*(\d+)[^\d]*\)/);
956
920
  if (!yearMatch) {
957
- if (isVerbose()) spinner_default.info(`skipped ${entry}`);
921
+ if (isVerbose()) spinner5.info(`skipped ${entry}`);
958
922
  skipped++;
959
923
  continue;
960
924
  }
961
925
  const year = yearMatch[0];
962
926
  if (year.length !== 6) {
963
- if (isVerbose()) spinner_default.info(`skipped ${entry}`);
927
+ if (isVerbose()) spinner5.info(`skipped ${entry}`);
964
928
  skipped++;
965
929
  continue;
966
930
  }
@@ -971,20 +935,20 @@ var rename = async ({ dir: inputDir, type }) => {
971
935
  return videoExtensions_default.includes(ext2) && title.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
972
936
  });
973
937
  if (!video) {
974
- if (isVerbose()) spinner_default.info(`skipped ${entry}`);
938
+ if (isVerbose()) spinner5.info(`skipped ${entry}`);
975
939
  skipped++;
976
940
  continue;
977
941
  }
978
942
  const ext = video.match(/([^.]+$)/)?.[0];
979
943
  if (!ext) {
980
- if (isVerbose()) spinner_default.info(`skipped ${entry}`);
944
+ if (isVerbose()) spinner5.info(`skipped ${entry}`);
981
945
  skipped++;
982
946
  continue;
983
947
  }
984
948
  const yearNum = parseInt(year.replace(/\D/g, ""));
985
949
  const formatted = formatMovieName(movieFormat, title, yearNum);
986
950
  if (entry === formatted && video === `${formatted}.${ext}`) {
987
- if (isVerbose()) spinner_default.info(`skipped ${entry}`);
951
+ if (isVerbose()) spinner5.info(`skipped ${entry}`);
988
952
  skipped++;
989
953
  continue;
990
954
  }
@@ -1007,25 +971,26 @@ var rename = async ({ dir: inputDir, type }) => {
1007
971
  renameSync(folderOld, folderNew);
1008
972
  recordRename(sessionId, fileOld, fileNew);
1009
973
  recordRename(sessionId, folderOld, folderNew);
1010
- spinner_default.succeed(formatted);
974
+ spinner5.succeed(formatted);
1011
975
  renamed++;
1012
976
  }
1013
- spinner_default.succeed(`renamed ${renamed} files`);
1014
- if (removed) spinner_default.info(`removed ${removed} files`);
1015
- spinner_default.info(`skipped ${skipped} files`);
1016
- spinner_default.succeed(`done in ${Color7.green.encoder(dir)}`);
1017
- spinner_default.stop();
977
+ spinner5.succeed(`renamed ${renamed} files`);
978
+ if (removed) spinner5.info(`removed ${removed} files`);
979
+ spinner5.info(`skipped ${skipped} files`);
980
+ spinner5.succeed(`done in ${Color7.green.encoder(dir)}`);
981
+ spinner5.stop();
1018
982
  };
1019
983
  var rename_default = rename;
1020
984
 
1021
985
  // src/actions/reset.ts
1022
986
  import { existsSync as existsSync8, readdirSync as readdirSync6, renameSync as renameSync2 } from "fs";
1023
987
  import { basename as basename2, dirname, resolve as resolve7, sep } from "path";
1024
- import { Color as Color8 } from "termkit";
988
+ import { Color as Color8, Spinner as Spinner6 } from "termkit";
989
+ var spinner6 = new Spinner6();
1025
990
  var reset = async ({ dir: inputDir, double }) => {
1026
991
  let dir = inputDir;
1027
- spinner_default.text = `resetting episodes in ${Color8.white.encoder(dir)}`;
1028
- spinner_default.start();
992
+ spinner6.update(`resetting episodes in ${Color8.white.encoder(dir)}`);
993
+ spinner6.start();
1029
994
  dir = resolve7(dir);
1030
995
  if (!existsSync8(dir)) throw new Error(`dir ${dir} does not exist`);
1031
996
  const list2 = readdirSync6(dir).sort();
@@ -1041,7 +1006,7 @@ var reset = async ({ dir: inputDir, double }) => {
1041
1006
  if (!seasonNum) throw new Error(`unable to identify season number`);
1042
1007
  const parentFolder = basename2(dirname(dir));
1043
1008
  const showTitle = parentFolder.match(/^(.+?)\s*(?:\(\d{4}\))?$/)?.[1]?.trim() || void 0;
1044
- spinner_default.info(`identified as season ${seasonNum}${showTitle ? ` of ${showTitle}` : ""}`);
1009
+ spinner6.info(`identified as season ${seasonNum}${showTitle ? ` of ${showTitle}` : ""}`);
1045
1010
  const sublist = list2.filter((f) => {
1046
1011
  const ext = f.match(/([^.]+$)/)?.[0];
1047
1012
  return videoExtensions_default.includes(ext) && f.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
@@ -1053,7 +1018,7 @@ var reset = async ({ dir: inputDir, double }) => {
1053
1018
  const episodeFormat = getConfig().format?.episode;
1054
1019
  let renamed = 0, skipped = other.length;
1055
1020
  for (const [index, i] of sublist.entries()) {
1056
- spinner_default.text = `resetting episodes in ${Color8.white.encoder(dir)} ${index}/${list2.length}`;
1021
+ spinner6.update(`resetting episodes in ${Color8.white.encoder(dir)} ${index}/${list2.length}`);
1057
1022
  const ext = i.match(/([^.]+$)/)?.[0];
1058
1023
  const episode = double ? index * 2 + 1 : index + 1;
1059
1024
  const name = `${formatEpisode(seasonNum, episode, episodeFormat, double, showTitle)}.${ext}`;
@@ -1064,17 +1029,17 @@ var reset = async ({ dir: inputDir, double }) => {
1064
1029
  renameSync2(resolve7(dir, i), resolve7(dir, name));
1065
1030
  renamed++;
1066
1031
  }
1067
- spinner_default.succeed(`renamed ${renamed} files`);
1068
- spinner_default.info(`skipped ${skipped} files`);
1069
- spinner_default.succeed(`done in ${Color8.green.encoder(dir)}`);
1070
- spinner_default.stop();
1032
+ spinner6.succeed(`renamed ${renamed} files`);
1033
+ spinner6.info(`skipped ${skipped} files`);
1034
+ spinner6.succeed(`done in ${Color8.green.encoder(dir)}`);
1035
+ spinner6.stop();
1071
1036
  };
1072
1037
  var reset_default = reset;
1073
1038
 
1074
1039
  // src/actions/scan.ts
1075
1040
  import { cpSync, existsSync as existsSync10, linkSync, lstatSync as lstatSync4, mkdirSync as mkdirSync4, readdirSync as readdirSync7, renameSync as renameSync4, rmSync as rmSync3, statSync as statSync2 } from "fs";
1076
1041
  import { dirname as dirname2, resolve as resolve8 } from "path";
1077
- import { Color as Color9, MultiSelect as MultiSelect2, Select as Select2 } from "termkit";
1042
+ import { Color as Color9, MultiSelect as MultiSelect2, Select as Select2, Spinner as Spinner7 } from "termkit";
1078
1043
 
1079
1044
  // src/helpers/detectEdition.ts
1080
1045
  var EDITIONS = [
@@ -1235,6 +1200,7 @@ var searchTv = async (title, apiKey) => {
1235
1200
  var bookExtensions_default = ["epub", "mobi", "azw3", "azw"];
1236
1201
 
1237
1202
  // src/actions/scan.ts
1203
+ var spinner7 = new Spinner7();
1238
1204
  var sameDev = (a, b) => {
1239
1205
  try {
1240
1206
  let bExisting = b;
@@ -1268,6 +1234,24 @@ var containsBook = (dir, depth = 2) => readdirSync7(dir).some((f) => {
1268
1234
  }
1269
1235
  return false;
1270
1236
  });
1237
+ var containsPdf = (dir) => {
1238
+ try {
1239
+ return readdirSync7(dir).some((f) => /\.pdf$/i.test(f));
1240
+ } catch {
1241
+ return false;
1242
+ }
1243
+ };
1244
+ var countVideos = (dir) => {
1245
+ try {
1246
+ return readdirSync7(dir).filter((f) => {
1247
+ if (/\bsample\b/i.test(f)) return false;
1248
+ const ext = f.match(/([^.]+$)/)?.[0];
1249
+ return !!(ext && videoExtensions_default.includes(ext));
1250
+ }).length;
1251
+ } catch {
1252
+ return 0;
1253
+ }
1254
+ };
1271
1255
  var isTvEpisodeName = (name) => /S\d{2,3}E\d{2,3}/i.test(name) || /\d+x\d{2,3}/i.test(name);
1272
1256
  var isSeasonDirName = (name) => !isTvEpisodeName(name) && /(?:^|[.\s_-])(?:season|s)\s*0*\d+(?:[.\s_-]|$)/i.test(name);
1273
1257
  var gatherEntries = (source) => {
@@ -1423,8 +1407,12 @@ var typeColor = {
1423
1407
  book: (s) => Color9.yellow.encoder(s),
1424
1408
  ps3: (s) => Color9.magenta.encoder(s)
1425
1409
  };
1426
- var typeGlyph = (t) => typeColor[t]("\u25CF");
1410
+ var typeGlyph = (t) => typeColor[t]("?");
1411
+ var checkGlyph = (t) => typeColor[t]("\u2714");
1412
+ var greyGlyph = Color9.white.faint.encoder("\u25CF");
1413
+ var warnGlyph = Color9.yellow.encoder("\u26A0");
1427
1414
  var typeTag = (t) => isVerbose() ? Color9.white.faint.encoder(` (${t})`) : "";
1415
+ var sortByEntry = (arr) => arr.sort((a, b) => a.entry.localeCompare(b.entry, void 0, { sensitivity: "base" }));
1428
1416
  var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactive }) => {
1429
1417
  const config = getConfig();
1430
1418
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
@@ -1432,19 +1420,20 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1432
1420
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1433
1421
  const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
1434
1422
  const specialsFolder = config.specialsFolder ?? "Specials";
1423
+ const dryTag = dryRun ? Color9.white.faint.encoder(" [dry]") : "";
1435
1424
  const lookupMovie = async (parsed) => {
1436
1425
  let tmdbId;
1437
1426
  let resolvedTitle = parsed.title;
1438
1427
  let resolvedYear = parsed.year;
1439
1428
  if (config.tmdbApiKey) {
1440
- spinner_default.text = `TMDb: ${parsed.title}`;
1429
+ spinner7.update(`TMDb: ${parsed.title}`);
1441
1430
  const results = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1442
1431
  if (results.length === 1) {
1443
1432
  tmdbId = results[0].id;
1444
1433
  resolvedTitle = results[0].title;
1445
1434
  resolvedYear = results[0].year ?? parsed.year;
1446
1435
  } else if (results.length > 1) {
1447
- spinner_default.stop();
1436
+ spinner7.stop();
1448
1437
  const select = new Select2();
1449
1438
  const items = results.map((r) => ({
1450
1439
  label: r.year ? `${r.title} (${r.year})` : r.title,
@@ -1452,7 +1441,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1452
1441
  ...r
1453
1442
  }));
1454
1443
  const picked = await select.ask(`Multiple movies found for "${parsed.title}":`, items);
1455
- spinner_default.start();
1444
+ spinner7.start();
1456
1445
  if (picked) {
1457
1446
  tmdbId = picked.id;
1458
1447
  resolvedTitle = picked.title;
@@ -1467,12 +1456,12 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1467
1456
  const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1468
1457
  const destFolder = resolve8(destRoot, folderName);
1469
1458
  if (existsSync10(destFolder)) {
1470
- spinner_default.warn(`already exists: ${folderName}`);
1459
+ spinner7.log(`${typeColor.movie(folderName)}${typeTag("movie")}`, greyGlyph);
1471
1460
  return false;
1472
1461
  }
1473
1462
  const videoFile = isDir ? findVideo(entryPath) : entry;
1474
1463
  if (!videoFile) {
1475
- if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1464
+ spinner7.log(`${entry}${typeTag("movie")}`, warnGlyph);
1476
1465
  return false;
1477
1466
  }
1478
1467
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
@@ -1493,7 +1482,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1493
1482
  linkSync(videoSourcePath, destVideoPath);
1494
1483
  mode = "hardlink";
1495
1484
  } catch {
1496
- spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1485
+ spinner7.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1497
1486
  cpSync(videoSourcePath, destVideoPath);
1498
1487
  mode = "copy";
1499
1488
  }
@@ -1519,27 +1508,33 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1519
1508
  recordImport(sessionId, entryPath, destFolder, "move", tmdbId, "movie");
1520
1509
  }
1521
1510
  }
1522
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeGlyph("movie")} ${typeColor.movie(folderName)}${typeTag("movie")}`);
1511
+ spinner7.log(`${typeColor.movie(folderName)}${typeTag("movie")}${dryTag}`, checkGlyph("movie"));
1523
1512
  return true;
1524
1513
  };
1525
- spinner_default.start();
1514
+ spinner7.start();
1526
1515
  if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1527
1516
  let imported = 0, skipped = 0;
1528
1517
  const pendingMovies = [];
1529
1518
  const pendingTv = [];
1519
+ const pendingBooks = [];
1520
+ const pendingAnime = [];
1530
1521
  const ignoreSet = new Set(config.ignore ?? []);
1531
1522
  const seenIgnored = /* @__PURE__ */ new Set();
1532
1523
  for (const source of config.sources) {
1533
1524
  if (!existsSync10(source)) {
1534
- spinner_default.warn(`source not found: ${Color9.white.encoder(source)}`);
1525
+ spinner7.warn(`source not found: ${Color9.white.encoder(source)}`);
1535
1526
  continue;
1536
1527
  }
1537
- spinner_default.text = `scanning ${Color9.white.encoder(source)}`;
1538
- for (const { entry, entryPath, isDir } of gatherEntries(source)) {
1539
- spinner_default.text = `scanning: ${entry}`;
1528
+ spinner7.update(`scanning ${Color9.white.encoder(source)}`);
1529
+ const entries = gatherEntries(source).sort(
1530
+ (a, b) => a.entry.localeCompare(b.entry, void 0, { sensitivity: "base" })
1531
+ );
1532
+ for (const { entry, entryPath, isDir } of entries) {
1533
+ spinner7.update(`scanning: ${entry}`);
1540
1534
  if (ignoreSet.has(entry)) {
1541
1535
  seenIgnored.add(entry);
1542
- if (isVerbose()) spinner_default.info(`ignored: ${entry}`);
1536
+ if (isVerbose()) spinner7.log(entry, greyGlyph);
1537
+ skipped++;
1543
1538
  continue;
1544
1539
  }
1545
1540
  const ext = entry.match(/([^.]+$)/)?.[0];
@@ -1559,7 +1554,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1559
1554
  }
1560
1555
  const destRoot = config.dest[detectedType];
1561
1556
  if (!destRoot) {
1562
- if (isVerbose()) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1557
+ if (isVerbose()) spinner7.log(`${entry}${typeTag(detectedType)}`, greyGlyph);
1563
1558
  skipped++;
1564
1559
  continue;
1565
1560
  }
@@ -1573,7 +1568,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1573
1568
  const destName = `${nameMatch[0]} [${id}]`;
1574
1569
  const destPath = resolve8(destRoot, destName);
1575
1570
  if (existsSync10(destPath)) {
1576
- spinner_default.warn(`already exists: ${destName}`);
1571
+ spinner7.log(`${typeColor.ps3(destName)}${typeTag("ps3")}`, greyGlyph);
1577
1572
  skipped++;
1578
1573
  continue;
1579
1574
  }
@@ -1581,14 +1576,14 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1581
1576
  moveFolder(entryPath, destPath);
1582
1577
  recordImport(sessionId, entryPath, destPath, "move", void 0, "ps3");
1583
1578
  }
1584
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeGlyph("ps3")} ${typeColor.ps3(destName)}${typeTag("ps3")}`);
1579
+ spinner7.log(`${typeColor.ps3(destName)}${typeTag("ps3")}${dryTag}`, checkGlyph("ps3"));
1585
1580
  imported++;
1586
1581
  continue;
1587
1582
  }
1588
1583
  if (detectedType === "book") {
1589
1584
  const destPath = resolve8(destRoot, entry);
1590
1585
  if (existsSync10(destPath)) {
1591
- spinner_default.warn(`already exists: ${entry}`);
1586
+ spinner7.log(`${typeColor.book(entry)}${typeTag("book")}`, greyGlyph);
1592
1587
  skipped++;
1593
1588
  continue;
1594
1589
  }
@@ -1606,20 +1601,36 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1606
1601
  }
1607
1602
  recordImport(sessionId, entryPath, destPath, "move", void 0, "book");
1608
1603
  }
1609
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeGlyph("book")} ${typeColor.book(entry)}${typeTag("book")}`);
1604
+ spinner7.log(`${typeColor.book(entry)}${typeTag("book")}${dryTag}`, checkGlyph("book"));
1610
1605
  imported++;
1611
1606
  continue;
1612
1607
  }
1608
+ if (detectedType === "movie" && isDir) {
1609
+ const videoCount = countVideos(entryPath);
1610
+ if (videoCount === 0) {
1611
+ if (containsPdf(entryPath)) {
1612
+ pendingBooks.push({ entry, entryPath });
1613
+ } else {
1614
+ if (isVerbose()) spinner7.log(entry, greyGlyph);
1615
+ skipped++;
1616
+ }
1617
+ continue;
1618
+ }
1619
+ if (videoCount >= 2) {
1620
+ pendingAnime.push({ entry, entryPath, videoCount });
1621
+ continue;
1622
+ }
1623
+ }
1613
1624
  const parsed = parseDownloadName(entry);
1614
1625
  if (!parsed) {
1615
- if (isVerbose()) spinner_default.info(`could not parse: ${entry}`);
1626
+ if (isVerbose()) spinner7.log(entry, greyGlyph);
1616
1627
  skipped++;
1617
1628
  continue;
1618
1629
  }
1619
1630
  if (detectedType === "movie") {
1620
1631
  const confidence = classifyMovieConfidence(entry);
1621
1632
  if (confidence === "skip") {
1622
- if (isVerbose()) spinner_default.info(`skipped (uncertain): ${entry}`);
1633
+ if (isVerbose()) spinner7.log(entry, greyGlyph);
1623
1634
  skipped++;
1624
1635
  continue;
1625
1636
  }
@@ -1633,14 +1644,14 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1633
1644
  let resolvedYear = parsed.year;
1634
1645
  if (config.tmdbApiKey) {
1635
1646
  if (detectedType === "tv") {
1636
- spinner_default.text = `TMDb: ${parsed.title}`;
1647
+ spinner7.update(`TMDb: ${parsed.title}`);
1637
1648
  const results = await searchTv(parsed.title, config.tmdbApiKey);
1638
1649
  if (results.length === 1) {
1639
1650
  tmdbId = results[0].id;
1640
1651
  resolvedTitle = results[0].title;
1641
1652
  resolvedYear = results[0].year ?? parsed.year;
1642
1653
  } else if (results.length > 1) {
1643
- spinner_default.stop();
1654
+ spinner7.stop();
1644
1655
  const select = new Select2();
1645
1656
  const items = results.map((r) => ({
1646
1657
  label: r.year ? `${r.title} (${r.year})` : r.title,
@@ -1648,7 +1659,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1648
1659
  ...r
1649
1660
  }));
1650
1661
  const picked = await select.ask(`Multiple shows found for "${parsed.title}":`, items);
1651
- spinner_default.start();
1662
+ spinner7.start();
1652
1663
  if (picked) {
1653
1664
  tmdbId = picked.id;
1654
1665
  resolvedTitle = picked.title;
@@ -1664,7 +1675,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1664
1675
  }
1665
1676
  if (detectedType === "tv") {
1666
1677
  if (parsed.season === void 0) {
1667
- if (isVerbose()) spinner_default.info(`could not detect season from: ${entry}`);
1678
+ if (isVerbose()) spinner7.log(entry, greyGlyph);
1668
1679
  skipped++;
1669
1680
  continue;
1670
1681
  }
@@ -1692,12 +1703,12 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1692
1703
  const seasonPath = resolve8(showPath, seasonFolderName);
1693
1704
  const videoFile = isDir ? findVideo(entryPath) : entry;
1694
1705
  if (!videoFile) {
1695
- if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
1706
+ spinner7.log(`${entry}${typeTag("tv")}`, warnGlyph);
1696
1707
  skipped++;
1697
1708
  continue;
1698
1709
  }
1699
1710
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1700
- if (tmdbId && config.tmdbApiKey) spinner_default.text = `TMDb: episode name for ${resolvedTitle}`;
1711
+ if (tmdbId && config.tmdbApiKey) spinner7.update(`TMDb: episode name for ${resolvedTitle}`);
1701
1712
  const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1702
1713
  const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, resolvedTitle, tmdbEpisodeName ?? void 0);
1703
1714
  const destVideoName = `${episodeName}.${videoExt}`;
@@ -1707,17 +1718,17 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1707
1718
  const isRepack = /\brepack\d*\b|\bproper\b/i.test(entry);
1708
1719
  let shouldReplace = force || isRepack;
1709
1720
  if (!shouldReplace && interactive) {
1710
- spinner_default.stop();
1721
+ spinner7.stop();
1711
1722
  const select = new Select2();
1712
1723
  const picked = await select.ask(`Already exists \u2014 replace?`, [
1713
1724
  { label: `${showFolderName} / ${seasonFolderName} / ${episodeName}`, value: "replace" },
1714
1725
  { label: "Skip", value: "skip" }
1715
1726
  ]);
1716
- spinner_default.start();
1727
+ spinner7.start();
1717
1728
  shouldReplace = picked?.value === "replace";
1718
1729
  }
1719
1730
  if (!shouldReplace) {
1720
- spinner_default.warn(`already exists: ${episodeName}`);
1731
+ spinner7.log(`${typeColor.tv(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}${typeTag("tv")}`, greyGlyph);
1721
1732
  skipped++;
1722
1733
  continue;
1723
1734
  }
@@ -1741,7 +1752,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1741
1752
  linkSync(videoSourcePath, destVideoPath);
1742
1753
  mode = "hardlink";
1743
1754
  } catch {
1744
- spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1755
+ spinner7.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1745
1756
  cpSync(videoSourcePath, destVideoPath);
1746
1757
  mode = "copy";
1747
1758
  }
@@ -1758,7 +1769,7 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1758
1769
  }
1759
1770
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId, "tv");
1760
1771
  }
1761
- spinner_default.succeed(`${dryRun ? "[dry] " : ""}${typeGlyph("tv")} ${typeColor.tv(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}${typeTag("tv")}`);
1772
+ spinner7.log(`${typeColor.tv(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}${typeTag("tv")}${dryTag}`, checkGlyph("tv"));
1762
1773
  imported++;
1763
1774
  continue;
1764
1775
  }
@@ -1769,12 +1780,11 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1769
1780
  }
1770
1781
  }
1771
1782
  }
1783
+ let uncertainMovies = 0;
1772
1784
  if (pendingMovies.length > 0) {
1773
- spinner_default.warn(`${pendingMovies.length} uncertain movie match${pendingMovies.length > 1 ? "es" : ""} skipped \u2014 use -i to review or -f to import all`);
1774
- for (const p of pendingMovies) spinner_default.info(` ${typeGlyph("movie")} ${p.entry.replace(/\/$/, "")}${typeTag("movie")}`);
1775
1785
  let toProcess = [];
1776
1786
  if (interactive) {
1777
- spinner_default.stop();
1787
+ spinner7.stop();
1778
1788
  const ms = new MultiSelect2({ allowSkip: true, search: true, maxHeight: 20 });
1779
1789
  const items = pendingMovies.map((p) => ({
1780
1790
  label: p.entry.replace(/\/$/, ""),
@@ -1782,12 +1792,15 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1782
1792
  ...p
1783
1793
  }));
1784
1794
  toProcess = await ms.ask("Select entries to import as movies:", items) ?? [];
1785
- spinner_default.start();
1786
- skipped += pendingMovies.length - toProcess.length;
1795
+ spinner7.start();
1787
1796
  } else if (force) {
1788
1797
  toProcess = pendingMovies;
1789
- } else {
1790
- skipped += pendingMovies.length;
1798
+ }
1799
+ const toSkip = pendingMovies.filter((p) => !toProcess.includes(p));
1800
+ uncertainMovies = toSkip.length;
1801
+ sortByEntry(toSkip);
1802
+ for (const p of toSkip) {
1803
+ spinner7.log(`${typeColor.movie(p.entry.replace(/\/$/, ""))}${typeTag("movie")}`, typeGlyph("movie"));
1791
1804
  }
1792
1805
  for (const p of toProcess) {
1793
1806
  const { tmdbId, resolvedTitle, resolvedYear } = await lookupMovie(p.parsed);
@@ -1799,9 +1812,22 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1799
1812
  }
1800
1813
  }
1801
1814
  if (pendingTv.length > 0) {
1802
- spinner_default.warn(`${pendingTv.length} TV show${pendingTv.length > 1 ? "s" : ""} skipped \u2014 no matching folder in destination`);
1803
- for (const p of pendingTv) spinner_default.info(` ${typeGlyph("tv")} ${p.resolvedTitle} \u2014 ${p.entry.replace(/\/$/, "")}${typeTag("tv")}`);
1804
- skipped += pendingTv.length;
1815
+ pendingTv.sort((a, b) => a.resolvedTitle.localeCompare(b.resolvedTitle, void 0, { sensitivity: "base" }));
1816
+ for (const p of pendingTv) {
1817
+ spinner7.log(`${typeColor.tv(p.resolvedTitle)} \u2014 ${p.entry.replace(/\/$/, "")}${typeTag("tv")}`, typeGlyph("tv"));
1818
+ }
1819
+ }
1820
+ if (pendingBooks.length > 0) {
1821
+ sortByEntry(pendingBooks);
1822
+ for (const p of pendingBooks) {
1823
+ spinner7.log(`${typeColor.book(p.entry)}${typeTag("book")}`, typeGlyph("book"));
1824
+ }
1825
+ }
1826
+ if (pendingAnime.length > 0) {
1827
+ sortByEntry(pendingAnime);
1828
+ for (const p of pendingAnime) {
1829
+ spinner7.log(`${typeColor.tv(p.entry)} (${p.videoCount} video${p.videoCount > 1 ? "s" : ""})${typeTag("tv")}`, typeGlyph("tv"));
1830
+ }
1805
1831
  }
1806
1832
  if (ignoreSet.size > 0) {
1807
1833
  const stale = [...ignoreSet].filter((name) => !seenIgnored.has(name));
@@ -1809,25 +1835,31 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, auto, force, interactiv
1809
1835
  const updated = config.ignore.filter((name) => !stale.includes(name));
1810
1836
  config.ignore = updated;
1811
1837
  saveConfig(config);
1812
- for (const name of stale) spinner_default.info(`removed from ignore list (not found): ${Color9.white.encoder(name)}`);
1838
+ for (const name of stale) spinner7.info(`removed from ignore list (not found): ${Color9.white.encoder(name)}`);
1813
1839
  }
1814
1840
  }
1815
- spinner_default.succeed(`${dryRun ? "would import" : "imported"} ${imported} items`);
1816
- if (skipped) spinner_default.info(`skipped ${skipped} items`);
1817
- spinner_default.stop();
1841
+ const summaryParts = [`${dryRun ? "would import" : "imported"} ${imported}`];
1842
+ if (skipped) summaryParts.push(`skipped ${skipped}`);
1843
+ if (uncertainMovies) summaryParts.push(`uncertain movie ${uncertainMovies}`);
1844
+ if (pendingTv.length) summaryParts.push(`uncertain tv ${pendingTv.length}`);
1845
+ if (pendingBooks.length) summaryParts.push(`uncertain book ${pendingBooks.length}`);
1846
+ if (pendingAnime.length) summaryParts.push(`uncertain anime ${pendingAnime.length}`);
1847
+ spinner7.succeed(summaryParts.join(", "));
1848
+ spinner7.stop();
1818
1849
  };
1819
1850
  var scan_default = scan;
1820
1851
 
1821
1852
  // src/actions/undo.ts
1822
1853
  import { existsSync as existsSync11, renameSync as renameSync5 } from "fs";
1823
- import { Color as Color10 } from "termkit";
1854
+ import { Color as Color10, Spinner as Spinner8 } from "termkit";
1855
+ var spinner8 = new Spinner8();
1824
1856
  var undo = async () => {
1825
- spinner_default.start();
1857
+ spinner8.start();
1826
1858
  const renameRecords = getLastSession();
1827
1859
  const importRecords = getLastImportSession();
1828
1860
  if (renameRecords.length === 0 && importRecords.length === 0) {
1829
- spinner_default.info("nothing to undo");
1830
- spinner_default.stop();
1861
+ spinner8.info("nothing to undo");
1862
+ spinner8.stop();
1831
1863
  return;
1832
1864
  }
1833
1865
  const useImports = importRecords.length > 0 && (renameRecords.length === 0 || importRecords[0].sessionId > renameRecords[0].sessionId);
@@ -1835,40 +1867,40 @@ var undo = async () => {
1835
1867
  let undone2 = 0;
1836
1868
  for (const record of renameRecords) {
1837
1869
  renameSync5(record.newPath, record.oldPath);
1838
- spinner_default.succeed(`${Color10.green.encoder(record.newPath)} \u2192 ${Color10.white.encoder(record.oldPath)}`);
1870
+ spinner8.succeed(`${Color10.green.encoder(record.newPath)} \u2192 ${Color10.white.encoder(record.oldPath)}`);
1839
1871
  undone2++;
1840
1872
  }
1841
1873
  deleteSession(renameRecords[0].sessionId);
1842
- spinner_default.succeed(`undid ${undone2} renames`);
1843
- spinner_default.stop();
1874
+ spinner8.succeed(`undid ${undone2} renames`);
1875
+ spinner8.stop();
1844
1876
  return;
1845
1877
  }
1846
1878
  let undone = 0;
1847
1879
  let skipped = 0;
1848
1880
  for (const record of importRecords) {
1849
1881
  if (record.mode !== "move") {
1850
- spinner_default.info(`skipped ${record.destinationPath} (${record.mode} \u2014 source file unchanged)`);
1882
+ spinner8.info(`skipped ${record.destinationPath} (${record.mode} \u2014 source file unchanged)`);
1851
1883
  skipped++;
1852
1884
  continue;
1853
1885
  }
1854
1886
  if (record.type === "tv") {
1855
- spinner_default.info(`skipped TV import \u2014 season folder cannot be cleanly reversed: ${record.destinationPath}`);
1887
+ spinner8.info(`skipped TV import \u2014 season folder cannot be cleanly reversed: ${record.destinationPath}`);
1856
1888
  skipped++;
1857
1889
  continue;
1858
1890
  }
1859
1891
  if (!existsSync11(record.destinationPath)) {
1860
- spinner_default.info(`skipped \u2014 destination no longer exists: ${record.destinationPath}`);
1892
+ spinner8.info(`skipped \u2014 destination no longer exists: ${record.destinationPath}`);
1861
1893
  skipped++;
1862
1894
  continue;
1863
1895
  }
1864
1896
  renameSync5(record.destinationPath, record.sourcePath);
1865
- spinner_default.succeed(`${Color10.green.encoder(record.destinationPath)} \u2192 ${Color10.white.encoder(record.sourcePath)}`);
1897
+ spinner8.succeed(`${Color10.green.encoder(record.destinationPath)} \u2192 ${Color10.white.encoder(record.sourcePath)}`);
1866
1898
  undone++;
1867
1899
  }
1868
1900
  deleteImportSession(importRecords[0].sessionId);
1869
- if (undone > 0) spinner_default.succeed(`undid ${undone} import${undone !== 1 ? "s" : ""}`);
1870
- if (skipped > 0) spinner_default.info(`skipped ${skipped} item${skipped !== 1 ? "s" : ""} (TV or non-move mode)`);
1871
- spinner_default.stop();
1901
+ if (undone > 0) spinner8.succeed(`undid ${undone} import${undone !== 1 ? "s" : ""}`);
1902
+ if (skipped > 0) spinner8.info(`skipped ${skipped} item${skipped !== 1 ? "s" : ""} (TV or non-move mode)`);
1903
+ spinner8.stop();
1872
1904
  };
1873
1905
  var undo_default = undo;
1874
1906
 
@@ -1876,7 +1908,8 @@ var undo_default = undo;
1876
1908
  import chokidar from "chokidar";
1877
1909
  import { cpSync as cpSync2, existsSync as existsSync12, linkSync as linkSync2, lstatSync as lstatSync5, mkdirSync as mkdirSync5, readdirSync as readdirSync8, renameSync as renameSync6, rmSync as rmSync4, statSync as statSync3 } from "fs";
1878
1910
  import { basename as basename4, dirname as dirname3, resolve as resolve9 } from "path";
1879
- import { Color as Color11 } from "termkit";
1911
+ import { Color as Color11, Spinner as Spinner9 } from "termkit";
1912
+ var spinner9 = new Spinner9();
1880
1913
  var sameDev2 = (a, b) => {
1881
1914
  try {
1882
1915
  let bExisting = b;
@@ -2016,7 +2049,7 @@ var processItem = async (entryPath, useHardlink, language, auto) => {
2016
2049
  }
2017
2050
  const destRoot = config.dest[detectedType];
2018
2051
  if (!destRoot) {
2019
- if (isVerbose()) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
2052
+ if (isVerbose()) spinner9.info(`no ${detectedType} destination configured, skipped: ${entry}`);
2020
2053
  return;
2021
2054
  }
2022
2055
  if (detectedType === "ps3") {
@@ -2026,18 +2059,18 @@ var processItem = async (entryPath, useHardlink, language, auto) => {
2026
2059
  const destName = `${nameMatch[0]} [${id}]`;
2027
2060
  const destPath = resolve9(destRoot, destName);
2028
2061
  if (existsSync12(destPath)) {
2029
- spinner_default.warn(`already exists: ${destName}`);
2062
+ spinner9.warn(`already exists: ${destName}`);
2030
2063
  return;
2031
2064
  }
2032
2065
  moveItem(entryPath, destPath);
2033
2066
  recordImport(sessionId, entryPath, destPath, "move", void 0, "ps3");
2034
- spinner_default.succeed(`imported ${Color11.green.encoder(destName)}`);
2067
+ spinner9.succeed(`imported ${Color11.green.encoder(destName)}`);
2035
2068
  return;
2036
2069
  }
2037
2070
  if (detectedType === "book") {
2038
2071
  const destPath = resolve9(destRoot, entry);
2039
2072
  if (existsSync12(destPath)) {
2040
- spinner_default.warn(`already exists: ${entry}`);
2073
+ spinner9.warn(`already exists: ${entry}`);
2041
2074
  return;
2042
2075
  }
2043
2076
  if (isDir || isBookDir) {
@@ -2052,17 +2085,17 @@ var processItem = async (entryPath, useHardlink, language, auto) => {
2052
2085
  }
2053
2086
  }
2054
2087
  recordImport(sessionId, entryPath, destPath, "move", void 0, "book");
2055
- spinner_default.succeed(`imported ${Color11.green.encoder(entry)}`);
2088
+ spinner9.succeed(`imported ${Color11.green.encoder(entry)}`);
2056
2089
  return;
2057
2090
  }
2058
2091
  const parsed = parseDownloadName(entry);
2059
2092
  if (!parsed) {
2060
- if (isVerbose()) spinner_default.info(`could not parse: ${entry}`);
2093
+ if (isVerbose()) spinner9.info(`could not parse: ${entry}`);
2061
2094
  return;
2062
2095
  }
2063
2096
  if (detectedType === "tv") {
2064
2097
  if (parsed.season === void 0) {
2065
- if (isVerbose()) spinner_default.info(`could not detect season from: ${entry}`);
2098
+ if (isVerbose()) spinner9.info(`could not detect season from: ${entry}`);
2066
2099
  return;
2067
2100
  }
2068
2101
  const registeredShow = getShowByTitle(parsed.title);
@@ -2076,14 +2109,14 @@ var processItem = async (entryPath, useHardlink, language, auto) => {
2076
2109
  showPath = resolve9(destRoot, showFolderName);
2077
2110
  upsertShow(showPath, null, parsed.title);
2078
2111
  } else {
2079
- if (isVerbose()) spinner_default.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
2112
+ if (isVerbose()) spinner9.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
2080
2113
  return;
2081
2114
  }
2082
2115
  const seasonFolderName = findSeasonFolder2(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
2083
2116
  const seasonPath = resolve9(showPath, seasonFolderName);
2084
2117
  const videoFile2 = isDir ? findVideo2(entryPath) : entry;
2085
2118
  if (!videoFile2) {
2086
- if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
2119
+ if (isVerbose()) spinner9.info(`no video found in: ${entry}`);
2087
2120
  return;
2088
2121
  }
2089
2122
  const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
@@ -2093,7 +2126,7 @@ var processItem = async (entryPath, useHardlink, language, auto) => {
2093
2126
  const destVideoPath = resolve9(seasonPath, destVideoName2);
2094
2127
  const videoSourcePath2 = isDir ? resolve9(entryPath, videoFile2) : entryPath;
2095
2128
  if (existsSync12(destVideoPath)) {
2096
- spinner_default.warn(`already exists: ${episodeName}`);
2129
+ spinner9.warn(`already exists: ${episodeName}`);
2097
2130
  return;
2098
2131
  }
2099
2132
  const dirFiles2 = isDir ? readdirSync8(entryPath) : [];
@@ -2109,7 +2142,7 @@ var processItem = async (entryPath, useHardlink, language, auto) => {
2109
2142
  linkSync2(videoSourcePath2, destVideoPath);
2110
2143
  mode = "hardlink";
2111
2144
  } catch {
2112
- spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
2145
+ spinner9.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
2113
2146
  cpSync2(videoSourcePath2, destVideoPath);
2114
2147
  mode = "copy";
2115
2148
  }
@@ -2125,19 +2158,19 @@ var processItem = async (entryPath, useHardlink, language, auto) => {
2125
2158
  if (isDir) rmSync4(entryPath, { recursive: true, force: true });
2126
2159
  }
2127
2160
  recordImport(sessionId, entryPath, seasonPath, mode, void 0, "tv");
2128
- spinner_default.succeed(`imported ${Color11.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
2161
+ spinner9.succeed(`imported ${Color11.green.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
2129
2162
  return;
2130
2163
  }
2131
2164
  const edition = detectEdition(entry);
2132
2165
  const folderName = formatMovieName(movieFormat, parsed.title, parsed.year, edition);
2133
2166
  const destFolder = resolve9(destRoot, folderName);
2134
2167
  if (existsSync12(destFolder)) {
2135
- spinner_default.warn(`already exists: ${folderName}`);
2168
+ spinner9.warn(`already exists: ${folderName}`);
2136
2169
  return;
2137
2170
  }
2138
2171
  const videoFile = isDir ? findVideo2(entryPath) : entry;
2139
2172
  if (!videoFile) {
2140
- if (isVerbose()) spinner_default.info(`no video found in: ${entry}`);
2173
+ if (isVerbose()) spinner9.info(`no video found in: ${entry}`);
2141
2174
  return;
2142
2175
  }
2143
2176
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
@@ -2157,7 +2190,7 @@ var processItem = async (entryPath, useHardlink, language, auto) => {
2157
2190
  linkSync2(videoSourcePath, destVideoPath);
2158
2191
  mode = "hardlink";
2159
2192
  } catch {
2160
- spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
2193
+ spinner9.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
2161
2194
  cpSync2(videoSourcePath, destVideoPath);
2162
2195
  mode = "copy";
2163
2196
  }
@@ -2182,7 +2215,7 @@ var processItem = async (entryPath, useHardlink, language, auto) => {
2182
2215
  }
2183
2216
  recordImport(sessionId, entryPath, destFolder, "move", void 0, "movie");
2184
2217
  }
2185
- spinner_default.succeed(`imported ${Color11.green.encoder(folderName)}`);
2218
+ spinner9.succeed(`imported ${Color11.green.encoder(folderName)}`);
2186
2219
  };
2187
2220
  var watch = async ({ hardlink = false, auto = false }) => {
2188
2221
  const config = getConfig();
@@ -2201,7 +2234,7 @@ var watch = async ({ hardlink = false, auto = false }) => {
2201
2234
  await processItem(entry, hardlink, language, auto);
2202
2235
  }
2203
2236
  } catch (err) {
2204
- spinner_default.fail(`error processing ${path}: ${err.message}`);
2237
+ spinner9.fail(`error processing ${path}: ${err.message}`);
2205
2238
  }
2206
2239
  }, 5e3)
2207
2240
  );
@@ -2213,10 +2246,10 @@ var watch = async ({ hardlink = false, auto = false }) => {
2213
2246
  });
2214
2247
  watcher.on("addDir", handle);
2215
2248
  watcher.on("add", handle);
2216
- spinner_default.start();
2217
- spinner_default.succeed(`watching ${config.sources.length} source${config.sources.length !== 1 ? "s" : ""}`);
2218
- for (const s of config.sources) spinner_default.info(` ${Color11.white.encoder(s)}`);
2219
- spinner_default.stop();
2249
+ spinner9.start();
2250
+ spinner9.succeed(`watching ${config.sources.length} source${config.sources.length !== 1 ? "s" : ""}`);
2251
+ for (const s of config.sources) spinner9.info(` ${Color11.white.encoder(s)}`);
2252
+ spinner9.stop();
2220
2253
  process.stdin.resume();
2221
2254
  };
2222
2255
  var watch_default = watch;