reelsort 0.1.0

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 ADDED
@@ -0,0 +1,1767 @@
1
+ // src/actions/config.ts
2
+ import cosmetic from "cosmetic";
3
+ import { resolve } from "path";
4
+
5
+ // src/config.ts
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+ var CONFIG_DIR = join(homedir(), ".config", "reelsort");
10
+ var CONFIG_PATH = join(CONFIG_DIR, "config.json");
11
+ var getConfig = () => {
12
+ if (!existsSync(CONFIG_PATH)) return { sources: [], dest: {} };
13
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
14
+ };
15
+ var saveConfig = (config) => {
16
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
17
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
18
+ };
19
+
20
+ // src/helpers/formatEpisode.ts
21
+ var DEFAULT_EPISODE_FORMAT = "{s}x{ee}";
22
+ var DEFAULT_SEASON_FORMAT = "Season {s}";
23
+ var renderEpisode = (format, season, episode, title, name) => {
24
+ return format.replace(/\{sss\}/g, season.toString().padStart(3, "0")).replace(/\{ss\}/g, season.toString().padStart(2, "0")).replace(/\{s\}/g, season.toString()).replace(/\{eee\}/g, episode.toString().padStart(3, "0")).replace(/\{ee\}/g, episode.toString().padStart(2, "0")).replace(/\{e\}/g, episode.toString()).replace(/\{title\}/g, title ?? "").replace(/\{name\}/g, name ?? "").replace(/\s+/g, " ").trim();
25
+ };
26
+ var formatSeasonFolder = (format, season) => format.replace(/\{sss\}/g, season.toString().padStart(3, "0")).replace(/\{ss\}/g, season.toString().padStart(2, "0")).replace(/\{s\}/g, season.toString()).trim();
27
+ var formatEpisode = (season, episode, format = DEFAULT_EPISODE_FORMAT, double = false, title, name) => {
28
+ if (double) {
29
+ const a = renderEpisode(format, season, episode, title, name);
30
+ const b = renderEpisode(format, season, episode + 1, title, name);
31
+ return `${a}-${b}`;
32
+ }
33
+ return renderEpisode(format, season, episode, title, name);
34
+ };
35
+
36
+ // src/helpers/formatName.ts
37
+ var DEFAULT_MOVIE_FORMAT = "{title} ({year})";
38
+ var formatMovieName = (template, title, year, edition) => {
39
+ const editionTag = edition ? ` {edition-${edition}}` : "";
40
+ let result = template.replace("{title}", title).replace("{edition}", editionTag);
41
+ if (year) {
42
+ result = result.replace("{year}", year.toString());
43
+ } else {
44
+ result = result.replace(/\s*[([{]\{year\}[)\]}]/g, "").replace("{year}", "");
45
+ }
46
+ return result.replace(/\s+/g, " ").trim();
47
+ };
48
+
49
+ // src/refs/spinner.ts
50
+ import { Spinner as TermSpinner } from "termpulse";
51
+ var Spinner = class {
52
+ spinner;
53
+ _isSpinning = false;
54
+ _text = "";
55
+ constructor() {
56
+ this.spinner = new TermSpinner();
57
+ }
58
+ get text() {
59
+ return this._text;
60
+ }
61
+ set text(t) {
62
+ this._text = t;
63
+ if (this._isSpinning) this.spinner.message(t);
64
+ }
65
+ get isSpinning() {
66
+ return this._isSpinning;
67
+ }
68
+ start(s) {
69
+ if (s) this._text = s;
70
+ this.spinner.start();
71
+ this._isSpinning = true;
72
+ return this;
73
+ }
74
+ info(s) {
75
+ this.spinner.info(s);
76
+ return this;
77
+ }
78
+ warn(s) {
79
+ this.spinner.warn(s);
80
+ return this;
81
+ }
82
+ fail(s) {
83
+ this.spinner.fail(s);
84
+ return this;
85
+ }
86
+ succeed(s) {
87
+ this.spinner.succeed(s);
88
+ return this;
89
+ }
90
+ stop() {
91
+ this.spinner.stop();
92
+ this._isSpinning = false;
93
+ process.stdin.resume();
94
+ return this;
95
+ }
96
+ };
97
+ var spinner_default = new Spinner();
98
+
99
+ // src/actions/config.ts
100
+ var DEST_TYPES = ["movie", "tv", "ps3"];
101
+ var configAdd = async ({ key, value }) => {
102
+ if (key !== "source") throw new Error(`unknown key '${key}', expected: source`);
103
+ const dir = resolve(value);
104
+ const config = getConfig();
105
+ if (config.sources.includes(dir)) {
106
+ spinner_default.start();
107
+ spinner_default.info(`source already configured: ${cosmetic.blue.encoder(dir)}`);
108
+ spinner_default.stop();
109
+ return;
110
+ }
111
+ config.sources.push(dir);
112
+ saveConfig(config);
113
+ spinner_default.start();
114
+ spinner_default.succeed(`added source: ${cosmetic.blue.encoder(dir)}`);
115
+ spinner_default.stop();
116
+ };
117
+ var configRemove = async ({ key, value }) => {
118
+ if (key !== "source") throw new Error(`unknown key '${key}', expected: source`);
119
+ const dir = resolve(value);
120
+ const config = getConfig();
121
+ const index = config.sources.indexOf(dir);
122
+ if (index === -1) {
123
+ spinner_default.start();
124
+ spinner_default.warn(`source not found: ${cosmetic.blue.encoder(dir)}`);
125
+ spinner_default.stop();
126
+ return;
127
+ }
128
+ config.sources.splice(index, 1);
129
+ saveConfig(config);
130
+ spinner_default.start();
131
+ spinner_default.succeed(`removed source: ${cosmetic.blue.encoder(dir)}`);
132
+ spinner_default.stop();
133
+ };
134
+ var configSet = async ({ key, subkey, value }) => {
135
+ const config = getConfig();
136
+ if (key === "language") {
137
+ config.language = subkey;
138
+ saveConfig(config);
139
+ spinner_default.start();
140
+ spinner_default.succeed(`set subtitle language: ${cosmetic.cyan.encoder(subkey)}`);
141
+ spinner_default.stop();
142
+ return;
143
+ }
144
+ if (key === "tmdb-key") {
145
+ config.tmdbApiKey = subkey;
146
+ saveConfig(config);
147
+ spinner_default.start();
148
+ spinner_default.succeed(`set TMDb API key`);
149
+ spinner_default.stop();
150
+ return;
151
+ }
152
+ if (key === "format") {
153
+ if (subkey === "episode") {
154
+ if (!value) throw new Error('missing format string for episode, e.g. "S{ss}E{ee}"');
155
+ if (!/\{e+\}/.test(value)) throw new Error("episode format must include an episode token: {e}, {ee}, or {eee}");
156
+ config.format = { ...config.format, episode: value };
157
+ } else if (subkey === "movie") {
158
+ if (!value) throw new Error('missing format string for movie, e.g. "{title} ({year})"');
159
+ if (!value.includes("{title}")) throw new Error("movie format must include {title}");
160
+ config.format = { ...config.format, movie: value };
161
+ } else if (subkey === "season") {
162
+ if (!value) throw new Error('missing format string for season, e.g. "Season {s}"');
163
+ if (!/\{s+\}/.test(value)) throw new Error("season format must include a season token: {s}, {ss}, or {sss}");
164
+ config.format = { ...config.format, season: value };
165
+ } else {
166
+ throw new Error(`unknown format key '${subkey}', expected: movie, episode, season`);
167
+ }
168
+ saveConfig(config);
169
+ spinner_default.start();
170
+ spinner_default.succeed(`set ${subkey} format: ${cosmetic.cyan.encoder(value ?? subkey)}`);
171
+ spinner_default.stop();
172
+ return;
173
+ }
174
+ if (key !== "dest") throw new Error(`unknown key '${key}', expected: dest, language, tmdb-key, format`);
175
+ if (!DEST_TYPES.includes(subkey)) {
176
+ throw new Error(`unknown type '${subkey}', expected: ${DEST_TYPES.join(", ")}`);
177
+ }
178
+ if (!value) throw new Error(`missing path for dest ${subkey}`);
179
+ const dir = resolve(value);
180
+ config.dest[subkey] = dir;
181
+ saveConfig(config);
182
+ spinner_default.start();
183
+ spinner_default.succeed(`set ${subkey} destination: ${cosmetic.cyan.encoder(dir)}`);
184
+ spinner_default.stop();
185
+ };
186
+ var configShow = async (_) => {
187
+ const config = getConfig();
188
+ console.log("\nSources:");
189
+ if (config.sources.length === 0) {
190
+ console.log(" (none)");
191
+ } else {
192
+ for (const s of config.sources) console.log(` ${cosmetic.blue.encoder(s)}`);
193
+ }
194
+ console.log("\nDestinations:");
195
+ const entries = DEST_TYPES.map((t) => ({ type: t, path: config.dest[t] })).filter((e) => e.path);
196
+ if (entries.length === 0) {
197
+ console.log(" (none)");
198
+ } else {
199
+ for (const { type, path } of entries) {
200
+ console.log(` ${type.padEnd(6)} ${cosmetic.cyan.encoder(path)}`);
201
+ }
202
+ }
203
+ console.log(`
204
+ Subtitle language: ${cosmetic.cyan.encoder(config.language ?? "eng (default)")}`);
205
+ console.log(`TMDb API key: ${config.tmdbApiKey ? cosmetic.green.encoder("configured") : cosmetic.red.encoder("not set")}`);
206
+ console.log(`Movie format: ${cosmetic.cyan.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
207
+ console.log(`Episode format: ${cosmetic.cyan.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
208
+ console.log(`Season folder: ${cosmetic.cyan.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
209
+ console.log();
210
+ };
211
+
212
+ // src/actions/probe.ts
213
+ import cosmetic2 from "cosmetic";
214
+ import { spawnSync } from "child_process";
215
+ import { existsSync as existsSync3, lstatSync, readdirSync } from "fs";
216
+ import { resolve as resolve2 } from "path";
217
+
218
+ // src/db.ts
219
+ import Database from "better-sqlite3";
220
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
221
+ import { homedir as homedir2 } from "os";
222
+ import { join as join2 } from "path";
223
+ var DB_DIR = join2(homedir2(), ".config", "reelsort");
224
+ var DB_PATH = join2(DB_DIR, "reelsort.db");
225
+ var _db = null;
226
+ var db = () => {
227
+ if (_db) return _db;
228
+ if (!existsSync2(DB_DIR)) mkdirSync2(DB_DIR, { recursive: true });
229
+ _db = new Database(DB_PATH);
230
+ _db.exec(`
231
+ CREATE TABLE IF NOT EXISTS shows (
232
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
233
+ path TEXT NOT NULL UNIQUE,
234
+ tmdbId INTEGER,
235
+ title TEXT,
236
+ ended INTEGER NOT NULL DEFAULT 0,
237
+ addedAt DATETIME DEFAULT CURRENT_TIMESTAMP
238
+ );
239
+ CREATE TABLE IF NOT EXISTS renameHistory (
240
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
241
+ sessionId TEXT NOT NULL,
242
+ oldPath TEXT NOT NULL,
243
+ newPath TEXT NOT NULL,
244
+ renamedAt DATETIME DEFAULT CURRENT_TIMESTAMP
245
+ );
246
+ CREATE TABLE IF NOT EXISTS mediaInfo (
247
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
248
+ filePath TEXT NOT NULL UNIQUE,
249
+ codec TEXT,
250
+ resolution TEXT,
251
+ width INTEGER,
252
+ height INTEGER,
253
+ duration REAL,
254
+ probedAt DATETIME DEFAULT CURRENT_TIMESTAMP
255
+ );
256
+ CREATE TABLE IF NOT EXISTS imports (
257
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
258
+ sessionId TEXT NOT NULL,
259
+ sourcePath TEXT NOT NULL,
260
+ destinationPath TEXT NOT NULL,
261
+ mode TEXT NOT NULL,
262
+ tmdbId INTEGER,
263
+ importedAt DATETIME DEFAULT CURRENT_TIMESTAMP
264
+ )
265
+ `);
266
+ try {
267
+ _db.exec("ALTER TABLE shows ADD COLUMN title TEXT");
268
+ } catch {
269
+ }
270
+ return _db;
271
+ };
272
+ var recordRename = (sessionId, oldPath, newPath) => {
273
+ db().prepare("INSERT INTO renameHistory (sessionId, oldPath, newPath) VALUES (?, ?, ?)").run(sessionId, oldPath, newPath);
274
+ };
275
+ var getLastSession = () => {
276
+ const last = db().prepare("SELECT sessionId FROM renameHistory ORDER BY id DESC LIMIT 1").get();
277
+ if (!last) return [];
278
+ return db().prepare("SELECT * FROM renameHistory WHERE sessionId = ? ORDER BY id DESC").all(last.sessionId);
279
+ };
280
+ var deleteSession = (sessionId) => {
281
+ db().prepare("DELETE FROM renameHistory WHERE sessionId = ?").run(sessionId);
282
+ };
283
+ var recordImport = (sessionId, sourcePath, destPath, mode, tmdbId) => {
284
+ db().prepare("INSERT INTO imports (sessionId, sourcePath, destinationPath, mode, tmdbId) VALUES (?, ?, ?, ?, ?)").run(sessionId, sourcePath, destPath, mode, tmdbId ?? null);
285
+ };
286
+ var getMediaInfo = (filePath) => {
287
+ return db().prepare("SELECT * FROM mediaInfo WHERE filePath = ?").get(filePath);
288
+ };
289
+ var upsertMediaInfo = (filePath, codec, resolution, width, height, duration) => {
290
+ db().prepare(`INSERT INTO mediaInfo (filePath, codec, resolution, width, height, duration, probedAt)
291
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
292
+ ON CONFLICT(filePath) DO UPDATE SET
293
+ codec = excluded.codec, resolution = excluded.resolution,
294
+ width = excluded.width, height = excluded.height,
295
+ duration = excluded.duration, probedAt = excluded.probedAt`).run(filePath, codec, resolution, width, height, duration);
296
+ };
297
+ var getImportByDest = (destPath) => {
298
+ return db().prepare("SELECT * FROM imports WHERE destinationPath = ? LIMIT 1").get(destPath);
299
+ };
300
+ var rowToShow = (row) => ({
301
+ ...row,
302
+ ended: row.ended === 1
303
+ });
304
+ var getShows = () => db().prepare("SELECT * FROM shows ORDER BY path ASC").all().map(rowToShow);
305
+ var upsertShow = (path, tmdbId, title) => {
306
+ db().prepare(`
307
+ INSERT INTO shows (path, tmdbId, title) VALUES (?, ?, ?)
308
+ ON CONFLICT(path) DO UPDATE SET
309
+ tmdbId = COALESCE(excluded.tmdbId, tmdbId),
310
+ title = COALESCE(excluded.title, title)
311
+ `).run(path, tmdbId, title ?? null);
312
+ };
313
+ var getShowByTitle = (title) => {
314
+ const t = title.toLowerCase();
315
+ return getShows().find((s) => {
316
+ if (s.title?.toLowerCase() === t) return true;
317
+ const folderTitle = (s.path.split("/").pop() ?? "").replace(/\s*\(\d{4}\).*$/, "").trim().toLowerCase();
318
+ return folderTitle === t;
319
+ }) ?? null;
320
+ };
321
+ var getCleanableImports = () => {
322
+ return db().prepare(`SELECT * FROM imports WHERE mode IN ('hardlink', 'copy') ORDER BY importedAt ASC`).all();
323
+ };
324
+ var deleteImport = (id) => {
325
+ db().prepare("DELETE FROM imports WHERE id = ?").run(id);
326
+ };
327
+ var getImportHistory = (limit = 10) => {
328
+ const sessionIds = db().prepare("SELECT sessionId FROM imports GROUP BY sessionId ORDER BY MAX(id) DESC LIMIT ?").all(limit);
329
+ return sessionIds.map(({ sessionId }) => ({
330
+ sessionId,
331
+ records: db().prepare("SELECT * FROM imports WHERE sessionId = ? ORDER BY id ASC").all(sessionId)
332
+ }));
333
+ };
334
+ var getHistory = (limit = 10) => {
335
+ const sessionIds = db().prepare("SELECT sessionId FROM renameHistory GROUP BY sessionId ORDER BY MAX(id) DESC LIMIT ?").all(limit);
336
+ return sessionIds.map(({ sessionId }) => ({
337
+ sessionId,
338
+ records: db().prepare("SELECT * FROM renameHistory WHERE sessionId = ? ORDER BY id DESC").all(sessionId)
339
+ }));
340
+ };
341
+
342
+ // src/refs/videoExtensions.json
343
+ var videoExtensions_default = [
344
+ "3g2",
345
+ "3gp",
346
+ "aaf",
347
+ "asf",
348
+ "avchd",
349
+ "avi",
350
+ "drc",
351
+ "flv",
352
+ "m2v",
353
+ "m4p",
354
+ "m4v",
355
+ "mkv",
356
+ "mng",
357
+ "mov",
358
+ "mp2",
359
+ "mp4",
360
+ "mpe",
361
+ "mpeg",
362
+ "mpg",
363
+ "mpv",
364
+ "mxf",
365
+ "nsv",
366
+ "ogg",
367
+ "ogv",
368
+ "qt",
369
+ "rm",
370
+ "rmvb",
371
+ "roq",
372
+ "svi",
373
+ "vob",
374
+ "webm",
375
+ "wmv",
376
+ "yuv"
377
+ ];
378
+
379
+ // src/actions/probe.ts
380
+ var DEST_TYPES2 = ["movie", "tv", "ps3"];
381
+ var CODEC_MAP = {
382
+ hevc: "x265",
383
+ h265: "x265",
384
+ h264: "x264",
385
+ avc: "x264",
386
+ av1: "AV1",
387
+ vp9: "VP9",
388
+ vp8: "VP8",
389
+ xvid: "XviD",
390
+ mpeg4: "XviD",
391
+ mpeg2video: "MPEG-2"
392
+ };
393
+ var deriveResolution = (width, height) => {
394
+ if (height >= 2160 || width >= 3840) return "2160p";
395
+ if (height >= 1080 || width >= 1920) return "1080p";
396
+ if (height >= 720 || width >= 1280) return "720p";
397
+ if (height >= 576 || width >= 768) return "576p";
398
+ return "480p";
399
+ };
400
+ var isFfprobeAvailable = () => {
401
+ const result = spawnSync("ffprobe", ["-version"], { encoding: "utf-8" });
402
+ return !result.error && result.status === 0;
403
+ };
404
+ var runFfprobe = (filePath) => {
405
+ const result = spawnSync("ffprobe", ["-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filePath], { encoding: "utf-8" });
406
+ if (result.error || result.status !== 0) return null;
407
+ try {
408
+ const data = JSON.parse(result.stdout);
409
+ const video = data.streams?.find((s) => s.codec_type === "video");
410
+ if (!video) return null;
411
+ const width = video.width ?? null;
412
+ const height = video.height ?? null;
413
+ return {
414
+ codec: CODEC_MAP[video.codec_name?.toLowerCase()] ?? video.codec_name ?? null,
415
+ resolution: width && height ? deriveResolution(width, height) : null,
416
+ width: width ?? null,
417
+ height: height ?? null,
418
+ duration: data.format?.duration ? parseFloat(data.format.duration) : null
419
+ };
420
+ } catch {
421
+ return null;
422
+ }
423
+ };
424
+ var walkVideoFiles = (dir, depth = 0, maxDepth = 3) => {
425
+ if (!existsSync3(dir) || depth > maxDepth) return [];
426
+ const results = [];
427
+ for (const entry of readdirSync(dir)) {
428
+ const entryPath = resolve2(dir, entry);
429
+ try {
430
+ if (lstatSync(entryPath).isDirectory()) {
431
+ results.push(...walkVideoFiles(entryPath, depth + 1, maxDepth));
432
+ } else {
433
+ const ext = entry.match(/([^.]+$)/)?.[0]?.toLowerCase();
434
+ if (ext && videoExtensions_default.includes(ext)) results.push(entryPath);
435
+ }
436
+ } catch {
437
+ }
438
+ }
439
+ return results;
440
+ };
441
+ var probe = async ({ type, force, verbose }) => {
442
+ spinner_default.start();
443
+ if (!isFfprobeAvailable()) {
444
+ spinner_default.fail("ffprobe not found \u2014 install ffmpeg to use this command");
445
+ spinner_default.stop();
446
+ return;
447
+ }
448
+ const config = getConfig();
449
+ const types = (type ? [type] : DEST_TYPES2).filter((t) => config.dest[t]);
450
+ if (types.length === 0) throw new Error("no destinations configured \u2014 run: reelsort config set dest movie <dir>");
451
+ let probed = 0, skipped = 0, failed = 0;
452
+ for (const t of types) {
453
+ const destRoot = config.dest[t];
454
+ if (!existsSync3(destRoot)) continue;
455
+ spinner_default.text = `scanning ${cosmetic2.blue.encoder(destRoot)}`;
456
+ const files = walkVideoFiles(destRoot);
457
+ for (const filePath of files) {
458
+ if (!force && getMediaInfo(filePath)) {
459
+ if (verbose) spinner_default.info(`already probed: ${filePath}`);
460
+ skipped++;
461
+ continue;
462
+ }
463
+ spinner_default.text = `probing ${cosmetic2.blue.encoder(filePath)}`;
464
+ const result = runFfprobe(filePath);
465
+ if (!result) {
466
+ if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
467
+ failed++;
468
+ continue;
469
+ }
470
+ upsertMediaInfo(filePath, result.codec, result.resolution, result.width, result.height, result.duration);
471
+ if (verbose) spinner_default.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
472
+ probed++;
473
+ }
474
+ }
475
+ spinner_default.succeed(`probed ${probed} files`);
476
+ if (skipped) spinner_default.info(`skipped ${skipped} already indexed`);
477
+ if (failed) spinner_default.warn(`failed ${failed} files`);
478
+ spinner_default.stop();
479
+ };
480
+ var probe_default = probe;
481
+
482
+ // src/actions/list.ts
483
+ import cosmetic3 from "cosmetic";
484
+ import { existsSync as existsSync4, lstatSync as lstatSync2, readdirSync as readdirSync3 } from "fs";
485
+ import { resolve as resolve4 } from "path";
486
+
487
+ // src/helpers/dirSize.ts
488
+ import { readdirSync as readdirSync2, statSync } from "fs";
489
+ import { resolve as resolve3 } from "path";
490
+ var dirSize = (dir) => {
491
+ let total = 0;
492
+ try {
493
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
494
+ const full = resolve3(dir, entry.name);
495
+ if (entry.isDirectory()) {
496
+ total += dirSize(full);
497
+ } else if (entry.isFile()) {
498
+ try {
499
+ total += statSync(full).size;
500
+ } catch {
501
+ }
502
+ }
503
+ }
504
+ } catch {
505
+ }
506
+ return total;
507
+ };
508
+ var formatSize = (bytes) => {
509
+ if (bytes >= 1099511627776) return `${(bytes / 1099511627776).toFixed(1)} TB`;
510
+ if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
511
+ if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
512
+ return `${(bytes / 1024).toFixed(1)} KB`;
513
+ };
514
+
515
+ // src/helpers/parseQuality.ts
516
+ var RESOLUTION_MAP = {
517
+ "480p": "480p",
518
+ "576p": "576p",
519
+ "720p": "720p",
520
+ "1080p": "1080p",
521
+ "2160p": "2160p",
522
+ "4k": "2160p",
523
+ "uhd": "2160p",
524
+ "8k": "8K"
525
+ };
526
+ var CODEC_MAP2 = {
527
+ x264: "x264",
528
+ h264: "x264",
529
+ avc: "x264",
530
+ x265: "x265",
531
+ h265: "x265",
532
+ hevc: "x265",
533
+ xvid: "XviD",
534
+ divx: "DivX"
535
+ };
536
+ var parseQuality = (filename) => {
537
+ const tokens = filename.toLowerCase().split(/[.\s_\-/\\]+/);
538
+ let resolution;
539
+ let codec;
540
+ for (const token of tokens) {
541
+ if (!resolution && RESOLUTION_MAP[token]) resolution = RESOLUTION_MAP[token];
542
+ if (!codec && CODEC_MAP2[token]) codec = CODEC_MAP2[token];
543
+ if (resolution && codec) break;
544
+ }
545
+ return { resolution, codec };
546
+ };
547
+ var normalizeResolution = (s) => RESOLUTION_MAP[s.toLowerCase()] ?? s;
548
+ var normalizeCodec = (s) => CODEC_MAP2[s.toLowerCase()] ?? s;
549
+
550
+ // src/refs/subtitleExtensions.json
551
+ var subtitleExtensions_default = ["srt", "sub", "idx", "ass", "ssa", "vtt", "sup"];
552
+
553
+ // src/actions/list.ts
554
+ var DEST_TYPES3 = ["movie", "tv", "ps3"];
555
+ var findVideoFile = (dir) => {
556
+ try {
557
+ return readdirSync3(dir).find((f) => {
558
+ const ext = f.match(/([^.]+$)/)?.[0]?.toLowerCase();
559
+ return ext && videoExtensions_default.includes(ext);
560
+ }) ?? null;
561
+ } catch {
562
+ return null;
563
+ }
564
+ };
565
+ var hasSubtitle = (dir) => {
566
+ try {
567
+ return readdirSync3(dir).some((f) => {
568
+ const ext = f.match(/([^.]+$)/)?.[0]?.toLowerCase();
569
+ return ext && subtitleExtensions_default.includes(ext);
570
+ });
571
+ } catch {
572
+ return false;
573
+ }
574
+ };
575
+ var parseLibraryFolder = (name) => {
576
+ const match = name.match(/^(.+?)\s*\((\d{4})\)/);
577
+ if (match) return { title: match[1].trim(), year: parseInt(match[2]) };
578
+ return { title: name };
579
+ };
580
+ var col = (s, width) => s.padEnd(width).substring(0, width);
581
+ var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter, sort }) => {
582
+ const config = getConfig();
583
+ const types = (type ? [type] : DEST_TYPES3).filter((t) => config.dest[t]);
584
+ if (types.length === 0) throw new Error("no destinations configured \u2014 run: reelsort config set dest movie <dir>");
585
+ for (const t of types) {
586
+ const destRoot = config.dest[t];
587
+ if (!existsSync4(destRoot)) {
588
+ console.log(`
589
+ ${t.toUpperCase()} ${cosmetic3.blue.encoder(destRoot)} (not found)`);
590
+ continue;
591
+ }
592
+ const folders = readdirSync3(destRoot).filter((f) => {
593
+ try {
594
+ return lstatSync2(resolve4(destRoot, f)).isDirectory();
595
+ } catch {
596
+ return false;
597
+ }
598
+ });
599
+ const entries = folders.map((folder) => {
600
+ const folderPath = resolve4(destRoot, folder);
601
+ const parsed = parseLibraryFolder(folder);
602
+ const videoFile = findVideoFile(folderPath);
603
+ const mediaInfo = videoFile ? getMediaInfo(resolve4(folderPath, videoFile)) : null;
604
+ const quality = mediaInfo ? { resolution: mediaInfo.resolution ?? void 0, codec: mediaInfo.codec ?? void 0 } : (() => {
605
+ const imp = getImportByDest(folderPath);
606
+ return imp ? parseQuality(imp.sourcePath) : {};
607
+ })();
608
+ return { ...parsed, ...quality, hasSub: hasSubtitle(folderPath), size: formatSize(dirSize(folderPath)) };
609
+ });
610
+ let filtered = entries;
611
+ if (missingSubs) filtered = filtered.filter((e) => !e.hasSub);
612
+ if (codecFilter) filtered = filtered.filter((e) => e.codec?.toLowerCase() === normalizeCodec(codecFilter).toLowerCase());
613
+ if (resFilter) filtered = filtered.filter((e) => e.resolution?.toLowerCase() === normalizeResolution(resFilter).toLowerCase());
614
+ filtered.sort((a, b) => {
615
+ if (sort === "title") return a.title.localeCompare(b.title);
616
+ const yearDiff = (b.year ?? 0) - (a.year ?? 0);
617
+ return yearDiff !== 0 ? yearDiff : a.title.localeCompare(b.title);
618
+ });
619
+ const titleW = Math.min(50, Math.max(10, ...filtered.map((e) => e.title.length)) + 2);
620
+ const divider = "\u2500".repeat(titleW + 44);
621
+ console.log(`
622
+ ${cosmetic3.yellow.encoder(t.toUpperCase())} ${cosmetic3.blue.encoder(destRoot)}`);
623
+ console.log(divider);
624
+ console.log(`${"Title".padEnd(titleW)} ${"Year".padEnd(6)} ${"Res".padEnd(6)} ${"Codec".padEnd(6)} ${"Size".padEnd(10)} Sub`);
625
+ console.log(divider);
626
+ for (const e of filtered) {
627
+ const sub = e.hasSub ? cosmetic3.green.encoder("\u2713") : cosmetic3.red.encoder("\u2717");
628
+ console.log(
629
+ `${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}`
630
+ );
631
+ }
632
+ console.log(divider);
633
+ console.log(`${filtered.length} of ${entries.length} item${entries.length !== 1 ? "s" : ""}`);
634
+ }
635
+ console.log();
636
+ };
637
+ var list_default = list;
638
+
639
+ // src/helpers/detectEdition.ts
640
+ var EDITIONS = [
641
+ { pattern: /director.?s?.?cut/i, name: "Director's Cut" },
642
+ { pattern: /final\.?cut/i, name: "Final Cut" },
643
+ { pattern: /extended\.?(cut|edition|version)?/i, name: "Extended" },
644
+ { pattern: /theatrical\.?(cut|version)?/i, name: "Theatrical" },
645
+ { pattern: /unrated\.?(cut|version)?/i, name: "Unrated" },
646
+ { pattern: /anniversary\.?edition/i, name: "Anniversary Edition" },
647
+ { pattern: /collector.?s?.?(edition|cut)/i, name: "Collector's Edition" },
648
+ { pattern: /special\.?edition/i, name: "Special Edition" }
649
+ ];
650
+ var detectEdition = (filename) => {
651
+ for (const { pattern, name } of EDITIONS) {
652
+ if (pattern.test(filename)) return name;
653
+ }
654
+ return null;
655
+ };
656
+
657
+ // src/actions/watch.ts
658
+ import chokidar from "chokidar";
659
+ import cosmetic4 from "cosmetic";
660
+ import { cpSync, existsSync as existsSync5, linkSync, lstatSync as lstatSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync4, renameSync, rmSync, statSync as statSync2 } from "fs";
661
+ import { basename, dirname, resolve as resolve5 } from "path";
662
+
663
+ // src/helpers/findSubtitle.ts
664
+ var LANGUAGE_ALIASES = {
665
+ eng: ["en", "english"],
666
+ fre: ["fr", "french"],
667
+ spa: ["es", "spanish"],
668
+ ger: ["de", "german"],
669
+ ita: ["it", "italian"],
670
+ por: ["pt", "portuguese"],
671
+ rus: ["ru", "russian"],
672
+ jpn: ["ja", "japanese"],
673
+ chi: ["zh", "chinese"]
674
+ };
675
+ var matchesLanguage = (filename, lang) => {
676
+ const base = filename.replace(/\.[^.]+$/, "").toLowerCase();
677
+ const codes = [lang.toLowerCase(), ...LANGUAGE_ALIASES[lang.toLowerCase()] ?? []];
678
+ return codes.some((code) => base.endsWith(`.${code}`) || base.endsWith(`_${code}`) || base.endsWith(`-${code}`) || base.includes(`.${code}.`));
679
+ };
680
+ var findSubtitle = (files, language) => {
681
+ const subs = files.filter((f) => {
682
+ const ext = f.match(/([^.]+$)/)?.[0]?.toLowerCase();
683
+ return ext && subtitleExtensions_default.includes(ext);
684
+ });
685
+ if (subs.length === 0) return null;
686
+ if (subs.length === 1) return subs[0];
687
+ return subs.find((f) => matchesLanguage(f, language)) ?? subs[0];
688
+ };
689
+
690
+ // src/helpers/titleCase.ts
691
+ import { convert } from "roman-numeral";
692
+ import toTitleCase from "to-title-case";
693
+ var titleCase_default = (s) => {
694
+ s = toTitleCase(s);
695
+ const ws = s.toLowerCase().split(" ");
696
+ for (let i = 1; i <= 100; i++) {
697
+ const roman = convert(i);
698
+ if (roman.length <= 1) continue;
699
+ if (ws.includes(roman.toLowerCase())) {
700
+ s = s.replace(new RegExp(roman, "i"), roman);
701
+ }
702
+ }
703
+ const ch = s.split("");
704
+ for (const [i, e] of ch.entries()) {
705
+ if (e !== "-" || !ch[i + 1]) continue;
706
+ if (ch[i + 1] === " " && ch[i + 2] && ch[i + 2] !== ch[i + 2].toUpperCase()) {
707
+ s = `${i ? s.substring(0, i) : ""}${`- ${ch[i + 2].toUpperCase()}`}${s.substring(i + 3, s.length)}`;
708
+ } else if (ch[i + 1] !== ch[i + 1].toUpperCase()) {
709
+ s = `${i ? s.substring(0, i) : ""}${`-${ch[i + 1].toUpperCase()}`}${s.substring(i + 2, s.length)}`;
710
+ }
711
+ }
712
+ return s;
713
+ };
714
+
715
+ // src/helpers/parseDownloadName.ts
716
+ var QUALITY_TOKENS = /* @__PURE__ */ new Set([
717
+ "480p",
718
+ "576p",
719
+ "720p",
720
+ "1080p",
721
+ "2160p",
722
+ "4k",
723
+ "8k",
724
+ "bluray",
725
+ "bdrip",
726
+ "bdremux",
727
+ "brrip",
728
+ "webrip",
729
+ "web-dl",
730
+ "webdl",
731
+ "web",
732
+ "hdtv",
733
+ "dvdrip",
734
+ "dvdscr",
735
+ "cam",
736
+ "ts",
737
+ "scr",
738
+ "x264",
739
+ "x265",
740
+ "hevc",
741
+ "avc",
742
+ "h264",
743
+ "h265",
744
+ "xvid",
745
+ "divx",
746
+ "dts",
747
+ "ac3",
748
+ "aac",
749
+ "mp3",
750
+ "truehd",
751
+ "atmos",
752
+ "dd5",
753
+ "hdr",
754
+ "hdr10",
755
+ "hlg",
756
+ "dv",
757
+ "dolby",
758
+ "remux",
759
+ "proper",
760
+ "repack",
761
+ "extended",
762
+ "theatrical",
763
+ "unrated",
764
+ "multi",
765
+ "dubbed",
766
+ "subbed",
767
+ "internal"
768
+ ]);
769
+ var TV_PATTERN = /^(.*?)[.\s_-]*(?:S(\d{2,3})E(\d{2,3})|(\d{1,2})x(\d{2,3})|Season[\s.](\d+))/i;
770
+ var parseDownloadName = (name) => {
771
+ const base = name.replace(/\.[a-z0-9]{2,4}$/i, "");
772
+ const tvMatch = TV_PATTERN.exec(base);
773
+ if (tvMatch) {
774
+ const raw = tvMatch[1].replace(/[._]/g, " ").replace(/\[.*?\]/g, "").trim();
775
+ const title = titleCase_default(raw);
776
+ if (!title) return null;
777
+ const season = parseInt(tvMatch[2] ?? tvMatch[4] ?? tvMatch[6]);
778
+ const episode = parseInt(tvMatch[3] ?? tvMatch[5]);
779
+ return {
780
+ title,
781
+ type: "tv",
782
+ season: isNaN(season) ? void 0 : season,
783
+ episode: isNaN(episode) ? void 0 : episode
784
+ };
785
+ }
786
+ const normalized = base.replace(/[._]/g, " ").replace(/\[.*?\]/g, "").trim();
787
+ const tokens = normalized.split(/\s+/);
788
+ let stopIndex = tokens.length;
789
+ let year;
790
+ for (let i = 0; i < tokens.length; i++) {
791
+ const n = parseInt(tokens[i]);
792
+ if (!isNaN(n) && tokens[i].length === 4 && n >= 1900 && n <= 2099) {
793
+ year = n;
794
+ stopIndex = i;
795
+ break;
796
+ }
797
+ if (QUALITY_TOKENS.has(tokens[i].toLowerCase())) {
798
+ stopIndex = i;
799
+ break;
800
+ }
801
+ }
802
+ const titleTokens = tokens.slice(0, stopIndex);
803
+ if (titleTokens.length === 0) return null;
804
+ return { title: titleCase_default(titleTokens.join(" ")), year, type: "movie" };
805
+ };
806
+
807
+ // src/helpers/tmdb.ts
808
+ var BASE = "https://api.themoviedb.org/3";
809
+ var TMDB_WEB = "https://www.themoviedb.org";
810
+ var searchMovie = async (title, year, apiKey) => {
811
+ const url = new URL(`${BASE}/search/movie`);
812
+ url.searchParams.set("api_key", apiKey);
813
+ url.searchParams.set("query", title);
814
+ if (year) url.searchParams.set("year", String(year));
815
+ try {
816
+ const res = await fetch(url.toString());
817
+ if (!res.ok) return null;
818
+ const data = await res.json();
819
+ const first = data.results[0];
820
+ if (!first) return null;
821
+ return {
822
+ id: first.id,
823
+ title: first.title,
824
+ year: first.release_date ? parseInt(first.release_date.slice(0, 4)) : void 0,
825
+ url: `${TMDB_WEB}/movie/${first.id}`
826
+ };
827
+ } catch {
828
+ return null;
829
+ }
830
+ };
831
+ var getEpisodeName = async (seriesId, season, episode, apiKey) => {
832
+ const url = new URL(`${BASE}/tv/${seriesId}/season/${season}/episode/${episode}`);
833
+ url.searchParams.set("api_key", apiKey);
834
+ try {
835
+ const res = await fetch(url.toString());
836
+ if (!res.ok) return null;
837
+ const data = await res.json();
838
+ return data.name ?? null;
839
+ } catch {
840
+ return null;
841
+ }
842
+ };
843
+ var searchTv = async (title, apiKey) => {
844
+ const url = new URL(`${BASE}/search/tv`);
845
+ url.searchParams.set("api_key", apiKey);
846
+ url.searchParams.set("query", title);
847
+ try {
848
+ const res = await fetch(url.toString());
849
+ if (!res.ok) return [];
850
+ const data = await res.json();
851
+ return data.results.slice(0, 5).map((r) => ({
852
+ id: r.id,
853
+ title: r.name,
854
+ year: r.first_air_date ? parseInt(r.first_air_date.slice(0, 4)) : void 0,
855
+ overview: r.overview || void 0,
856
+ url: `${TMDB_WEB}/tv/${r.id}`
857
+ }));
858
+ } catch {
859
+ return [];
860
+ }
861
+ };
862
+
863
+ // src/actions/watch.ts
864
+ var sameDev = (a, b) => {
865
+ try {
866
+ let bExisting = b;
867
+ while (!existsSync5(bExisting)) bExisting = dirname(bExisting);
868
+ return statSync2(a).dev === statSync2(bExisting).dev;
869
+ } catch {
870
+ return false;
871
+ }
872
+ };
873
+ var moveItem = (src, dest) => {
874
+ if (sameDev(src, dest)) {
875
+ renameSync(src, dest);
876
+ } else {
877
+ cpSync(src, dest, { recursive: true });
878
+ rmSync(src, { recursive: true, force: true });
879
+ }
880
+ };
881
+ var findVideo = (dir) => readdirSync4(dir).find((f) => {
882
+ const ext = f.match(/([^.]+$)/)?.[0];
883
+ return ext && videoExtensions_default.includes(ext);
884
+ }) ?? null;
885
+ var findSeasonFolder = (showPath, season) => {
886
+ if (!existsSync5(showPath)) return null;
887
+ const folders = readdirSync4(showPath).filter((f) => {
888
+ try {
889
+ return lstatSync3(resolve5(showPath, f)).isDirectory();
890
+ } catch {
891
+ return false;
892
+ }
893
+ });
894
+ return folders.find((f) => {
895
+ const match = f.match(/(?:season|s)\s*0*(\d+)/i);
896
+ return match && parseInt(match[1]) === season;
897
+ }) ?? null;
898
+ };
899
+ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
900
+ const config = getConfig();
901
+ const sessionId = (/* @__PURE__ */ new Date()).toISOString();
902
+ const entry = basename(entryPath);
903
+ const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
904
+ const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
905
+ const isDir = lstatSync3(entryPath).isDirectory();
906
+ const ext = entry.match(/([^.]+$)/)?.[0];
907
+ const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
908
+ if (!isDir && !isVideo) return;
909
+ let detectedType;
910
+ if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
911
+ detectedType = "ps3";
912
+ } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
913
+ detectedType = "tv";
914
+ } else {
915
+ detectedType = "movie";
916
+ }
917
+ const destRoot = config.dest[detectedType];
918
+ if (!destRoot) {
919
+ if (verbose) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
920
+ return;
921
+ }
922
+ if (detectedType === "ps3") {
923
+ const nameMatch = entry.match(/(?<=\[).+?(?=\])/);
924
+ const id = entry.split("-")[0];
925
+ if (!nameMatch || !id) return;
926
+ const destName = `${nameMatch[0]} [${id}]`;
927
+ const destPath = resolve5(destRoot, destName);
928
+ if (existsSync5(destPath)) {
929
+ spinner_default.warn(`already exists: ${destName}`);
930
+ return;
931
+ }
932
+ moveItem(entryPath, destPath);
933
+ recordImport(sessionId, entryPath, destPath, "move");
934
+ spinner_default.succeed(`imported ${cosmetic4.cyan.encoder(destName)}`);
935
+ return;
936
+ }
937
+ const parsed = parseDownloadName(entry);
938
+ if (!parsed) {
939
+ if (verbose) spinner_default.info(`could not parse: ${entry}`);
940
+ return;
941
+ }
942
+ if (detectedType === "tv") {
943
+ if (parsed.season === void 0) {
944
+ if (verbose) spinner_default.info(`could not detect season from: ${entry}`);
945
+ return;
946
+ }
947
+ const registeredShow = getShowByTitle(parsed.title);
948
+ let showPath;
949
+ let showFolderName;
950
+ if (registeredShow) {
951
+ showPath = registeredShow.path;
952
+ showFolderName = showPath.split("/").pop() ?? registeredShow.path;
953
+ } else if (auto) {
954
+ showFolderName = formatMovieName(movieFormat, parsed.title, parsed.year);
955
+ showPath = resolve5(destRoot, showFolderName);
956
+ upsertShow(showPath, null, parsed.title);
957
+ } else {
958
+ if (verbose) spinner_default.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
959
+ return;
960
+ }
961
+ const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
962
+ const seasonPath = resolve5(showPath, seasonFolderName);
963
+ const videoFile2 = isDir ? findVideo(entryPath) : entry;
964
+ if (!videoFile2) {
965
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
966
+ return;
967
+ }
968
+ const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
969
+ const tmdbEpisodeName = registeredShow?.tmdbId && config.tmdbApiKey ? await getEpisodeName(registeredShow.tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
970
+ const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, parsed.title, tmdbEpisodeName ?? void 0);
971
+ const destVideoName2 = `${episodeName}.${videoExt2}`;
972
+ const destVideoPath = resolve5(seasonPath, destVideoName2);
973
+ const videoSourcePath2 = isDir ? resolve5(entryPath, videoFile2) : entryPath;
974
+ if (existsSync5(destVideoPath)) {
975
+ spinner_default.warn(`already exists: ${episodeName}`);
976
+ return;
977
+ }
978
+ const dirFiles2 = isDir ? readdirSync4(entryPath) : [];
979
+ const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
980
+ const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
981
+ const subtitleSourcePath2 = subtitle2 ? resolve5(entryPath, subtitle2) : null;
982
+ const destSubtitleName2 = subtitle2 && subtitleExt2 ? `${episodeName}.${subtitleExt2}` : null;
983
+ mkdirSync3(seasonPath, { recursive: true });
984
+ let mode = "move";
985
+ if (useHardlink) {
986
+ try {
987
+ if (!sameDev(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
988
+ linkSync(videoSourcePath2, destVideoPath);
989
+ mode = "hardlink";
990
+ } catch {
991
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
992
+ cpSync(videoSourcePath2, destVideoPath);
993
+ mode = "copy";
994
+ }
995
+ if (subtitleSourcePath2 && destSubtitleName2) cpSync(subtitleSourcePath2, resolve5(seasonPath, destSubtitleName2));
996
+ } else {
997
+ if (sameDev(videoSourcePath2, seasonPath)) {
998
+ renameSync(videoSourcePath2, destVideoPath);
999
+ } else {
1000
+ cpSync(videoSourcePath2, destVideoPath);
1001
+ rmSync(videoSourcePath2);
1002
+ }
1003
+ if (subtitleSourcePath2 && destSubtitleName2) renameSync(subtitleSourcePath2, resolve5(seasonPath, destSubtitleName2));
1004
+ if (isDir) rmSync(entryPath, { recursive: true, force: true });
1005
+ }
1006
+ recordImport(sessionId, entryPath, seasonPath, mode);
1007
+ spinner_default.succeed(`imported ${cosmetic4.cyan.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1008
+ return;
1009
+ }
1010
+ const edition = detectEdition(entry);
1011
+ const folderName = formatMovieName(movieFormat, parsed.title, parsed.year, edition);
1012
+ const destFolder = resolve5(destRoot, folderName);
1013
+ if (existsSync5(destFolder)) {
1014
+ spinner_default.warn(`already exists: ${folderName}`);
1015
+ return;
1016
+ }
1017
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1018
+ if (!videoFile) {
1019
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1020
+ return;
1021
+ }
1022
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1023
+ const destVideoName = `${folderName}.${videoExt}`;
1024
+ const videoSourcePath = isDir ? resolve5(entryPath, videoFile) : entryPath;
1025
+ const dirFiles = isDir ? readdirSync4(entryPath) : [];
1026
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1027
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1028
+ const subtitleSourcePath = subtitle ? resolve5(entryPath, subtitle) : null;
1029
+ const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1030
+ if (useHardlink) {
1031
+ mkdirSync3(destFolder, { recursive: true });
1032
+ const destVideoPath = resolve5(destFolder, destVideoName);
1033
+ let mode;
1034
+ try {
1035
+ if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1036
+ linkSync(videoSourcePath, destVideoPath);
1037
+ mode = "hardlink";
1038
+ } catch {
1039
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1040
+ cpSync(videoSourcePath, destVideoPath);
1041
+ mode = "copy";
1042
+ }
1043
+ if (subtitleSourcePath && destSubtitleName) cpSync(subtitleSourcePath, resolve5(destFolder, destSubtitleName));
1044
+ recordImport(sessionId, entryPath, destFolder, mode);
1045
+ } else {
1046
+ if (isDir) {
1047
+ const keep = new Set([videoFile, subtitle].filter(Boolean));
1048
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) rmSync(resolve5(entryPath, f), { recursive: true, force: true });
1049
+ renameSync(videoSourcePath, resolve5(entryPath, destVideoName));
1050
+ if (subtitleSourcePath && destSubtitleName) renameSync(subtitleSourcePath, resolve5(entryPath, destSubtitleName));
1051
+ moveItem(entryPath, destFolder);
1052
+ } else {
1053
+ mkdirSync3(destFolder, { recursive: true });
1054
+ const destVideoPath = resolve5(destFolder, destVideoName);
1055
+ if (sameDev(videoSourcePath, destRoot)) {
1056
+ renameSync(videoSourcePath, destVideoPath);
1057
+ } else {
1058
+ cpSync(videoSourcePath, destVideoPath);
1059
+ rmSync(videoSourcePath);
1060
+ }
1061
+ }
1062
+ recordImport(sessionId, entryPath, destFolder, "move");
1063
+ }
1064
+ spinner_default.succeed(`imported ${cosmetic4.cyan.encoder(folderName)}`);
1065
+ };
1066
+ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1067
+ const config = getConfig();
1068
+ if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1069
+ const language = config.language ?? "eng";
1070
+ const pending = /* @__PURE__ */ new Map();
1071
+ const handle = (path) => {
1072
+ const existing = pending.get(path);
1073
+ if (existing) clearTimeout(existing);
1074
+ pending.set(
1075
+ path,
1076
+ setTimeout(async () => {
1077
+ pending.delete(path);
1078
+ try {
1079
+ await processItem(path, hardlink, verbose, language, auto);
1080
+ } catch (err) {
1081
+ spinner_default.fail(`error processing ${path}: ${err.message}`);
1082
+ }
1083
+ }, 5e3)
1084
+ );
1085
+ };
1086
+ const watcher = chokidar.watch(config.sources, {
1087
+ depth: 0,
1088
+ ignoreInitial: true,
1089
+ awaitWriteFinish: { stabilityThreshold: 5e3, pollInterval: 500 }
1090
+ });
1091
+ watcher.on("addDir", handle);
1092
+ watcher.on("add", handle);
1093
+ spinner_default.start();
1094
+ spinner_default.succeed(`watching ${config.sources.length} source${config.sources.length !== 1 ? "s" : ""}`);
1095
+ for (const s of config.sources) spinner_default.info(` ${cosmetic4.blue.encoder(s)}`);
1096
+ spinner_default.stop();
1097
+ process.stdin.resume();
1098
+ };
1099
+ var watch_default = watch;
1100
+
1101
+ // src/actions/clean.ts
1102
+ import cosmetic5 from "cosmetic";
1103
+ import { existsSync as existsSync6, rmSync as rmSync2 } from "fs";
1104
+ var parseOlderThan = (s) => {
1105
+ const match = s.match(/^(\d+)([dhm])$/);
1106
+ if (!match) return null;
1107
+ const n = parseInt(match[1]);
1108
+ const unit = match[2];
1109
+ const ms = unit === "d" ? n * 864e5 : unit === "h" ? n * 36e5 : n * 6e4;
1110
+ return ms;
1111
+ };
1112
+ var clean = async ({ dryRun, olderThan }) => {
1113
+ spinner_default.start();
1114
+ const imports = getCleanableImports();
1115
+ if (imports.length === 0) {
1116
+ spinner_default.info("nothing to clean");
1117
+ spinner_default.stop();
1118
+ return;
1119
+ }
1120
+ const cutoffMs = olderThan ? parseOlderThan(olderThan) : null;
1121
+ if (olderThan && cutoffMs === null) throw new Error(`invalid --older-than format, expected e.g. 14d, 6h, 30m`);
1122
+ let cleaned = 0, skipped = 0;
1123
+ for (const imp of imports) {
1124
+ if (cutoffMs !== null) {
1125
+ const age = Date.now() - new Date(imp.importedAt).getTime();
1126
+ if (age < cutoffMs) {
1127
+ skipped++;
1128
+ continue;
1129
+ }
1130
+ }
1131
+ if (!existsSync6(imp.sourcePath)) {
1132
+ if (!dryRun) deleteImport(imp.id);
1133
+ continue;
1134
+ }
1135
+ if (dryRun) {
1136
+ spinner_default.succeed(`[dry] would remove ${cosmetic5.blue.encoder(imp.sourcePath)}`);
1137
+ cleaned++;
1138
+ continue;
1139
+ }
1140
+ try {
1141
+ rmSync2(imp.sourcePath, { recursive: true, force: true });
1142
+ deleteImport(imp.id);
1143
+ spinner_default.succeed(`removed ${cosmetic5.blue.encoder(imp.sourcePath)}`);
1144
+ cleaned++;
1145
+ } catch {
1146
+ spinner_default.warn(`locked or inaccessible, skipped: ${cosmetic5.blue.encoder(imp.sourcePath)}`);
1147
+ skipped++;
1148
+ }
1149
+ }
1150
+ spinner_default.succeed(`cleaned ${cleaned} items`);
1151
+ if (skipped) spinner_default.info(`skipped ${skipped} items`);
1152
+ spinner_default.stop();
1153
+ };
1154
+ var clean_default = clean;
1155
+
1156
+ // src/actions/scan.ts
1157
+ import cosmetic6 from "cosmetic";
1158
+ import { cpSync as cpSync2, existsSync as existsSync7, linkSync as linkSync2, lstatSync as lstatSync4, mkdirSync as mkdirSync4, readdirSync as readdirSync5, renameSync as renameSync2, rmSync as rmSync3, statSync as statSync3 } from "fs";
1159
+ import { dirname as dirname2, resolve as resolve6 } from "path";
1160
+ import { Select } from "termpulse";
1161
+
1162
+ // src/helpers/hyperlink.ts
1163
+ var hyperlink = (url, text) => `\x1B]8;;${url}\x1B\\${text}\x1B]8;;\x1B\\`;
1164
+
1165
+ // src/actions/scan.ts
1166
+ var sameDev2 = (a, b) => {
1167
+ try {
1168
+ let bExisting = b;
1169
+ while (!existsSync7(bExisting)) bExisting = dirname2(bExisting);
1170
+ return statSync3(a).dev === statSync3(bExisting).dev;
1171
+ } catch {
1172
+ return false;
1173
+ }
1174
+ };
1175
+ var moveFolder = (src, dest) => {
1176
+ if (sameDev2(src, dest)) {
1177
+ renameSync2(src, dest);
1178
+ } else {
1179
+ cpSync2(src, dest, { recursive: true });
1180
+ rmSync3(src, { recursive: true, force: true });
1181
+ }
1182
+ };
1183
+ var findVideo2 = (dir) => readdirSync5(dir).find((f) => {
1184
+ const ext = f.match(/([^.]+$)/)?.[0];
1185
+ return ext && videoExtensions_default.includes(ext);
1186
+ }) ?? null;
1187
+ var findSeasonFolder2 = (showPath, season) => {
1188
+ if (!existsSync7(showPath)) return null;
1189
+ const folders = readdirSync5(showPath).filter((f) => {
1190
+ try {
1191
+ return lstatSync4(resolve6(showPath, f)).isDirectory();
1192
+ } catch {
1193
+ return false;
1194
+ }
1195
+ });
1196
+ return folders.find((f) => {
1197
+ const match = f.match(/(?:season|s)\s*0*(\d+)/i);
1198
+ return match && parseInt(match[1]) === season;
1199
+ }) ?? null;
1200
+ };
1201
+ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1202
+ const config = getConfig();
1203
+ const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1204
+ const language = config.language ?? "eng";
1205
+ const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1206
+ const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
1207
+ spinner_default.start();
1208
+ if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1209
+ let imported = 0, skipped = 0;
1210
+ for (const source of config.sources) {
1211
+ if (!existsSync7(source)) {
1212
+ spinner_default.warn(`source not found: ${cosmetic6.blue.encoder(source)}`);
1213
+ continue;
1214
+ }
1215
+ spinner_default.text = `scanning ${cosmetic6.blue.encoder(source)}`;
1216
+ for (const entry of readdirSync5(source)) {
1217
+ const entryPath = resolve6(source, entry);
1218
+ const isDir = lstatSync4(entryPath).isDirectory();
1219
+ const ext = entry.match(/([^.]+$)/)?.[0];
1220
+ const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1221
+ if (!isDir && !isVideo) {
1222
+ if (verbose) spinner_default.info(`skipped ${entry}`);
1223
+ skipped++;
1224
+ continue;
1225
+ }
1226
+ let detectedType;
1227
+ if (type) {
1228
+ detectedType = type;
1229
+ } else if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1230
+ detectedType = "ps3";
1231
+ } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1232
+ detectedType = "tv";
1233
+ } else {
1234
+ detectedType = "movie";
1235
+ }
1236
+ const destRoot = config.dest[detectedType];
1237
+ if (!destRoot) {
1238
+ if (verbose) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1239
+ skipped++;
1240
+ continue;
1241
+ }
1242
+ if (detectedType === "ps3") {
1243
+ const nameMatch = entry.match(/(?<=\[).+?(?=\])/);
1244
+ const id = entry.split("-")[0];
1245
+ if (!nameMatch || !id) {
1246
+ skipped++;
1247
+ continue;
1248
+ }
1249
+ const destName = `${nameMatch[0]} [${id}]`;
1250
+ const destPath = resolve6(destRoot, destName);
1251
+ if (existsSync7(destPath)) {
1252
+ spinner_default.warn(`already exists: ${destName}`);
1253
+ skipped++;
1254
+ continue;
1255
+ }
1256
+ if (!dryRun) {
1257
+ moveFolder(entryPath, destPath);
1258
+ recordImport(sessionId, entryPath, destPath, "move");
1259
+ }
1260
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${destName}`);
1261
+ imported++;
1262
+ continue;
1263
+ }
1264
+ const parsed = parseDownloadName(entry);
1265
+ if (!parsed) {
1266
+ if (verbose) spinner_default.info(`could not parse: ${entry}`);
1267
+ skipped++;
1268
+ continue;
1269
+ }
1270
+ let tmdbId;
1271
+ let resolvedTitle = parsed.title;
1272
+ let resolvedYear = parsed.year;
1273
+ if (config.tmdbApiKey) {
1274
+ if (detectedType === "tv") {
1275
+ const results = await searchTv(parsed.title, config.tmdbApiKey);
1276
+ if (results.length === 1) {
1277
+ tmdbId = results[0].id;
1278
+ resolvedTitle = results[0].title;
1279
+ resolvedYear = results[0].year ?? parsed.year;
1280
+ } else if (results.length > 1) {
1281
+ spinner_default.stop();
1282
+ const select = new Select();
1283
+ const items = results.map((r) => ({
1284
+ label: r.year ? `${r.title} (${r.year})` : r.title,
1285
+ description: [r.overview?.slice(0, 60), hyperlink(r.url, "themoviedb.org")].filter(Boolean).join(" \xB7 "),
1286
+ ...r
1287
+ }));
1288
+ const picked = await select.ask(`Multiple shows found for "${parsed.title}":`, items);
1289
+ spinner_default.start();
1290
+ if (picked) {
1291
+ tmdbId = picked.id;
1292
+ resolvedTitle = picked.title;
1293
+ resolvedYear = picked.year ?? parsed.year;
1294
+ }
1295
+ }
1296
+ } else {
1297
+ const tmdb = await searchMovie(parsed.title, parsed.year, config.tmdbApiKey);
1298
+ if (tmdb) {
1299
+ tmdbId = tmdb.id;
1300
+ resolvedTitle = tmdb.title;
1301
+ resolvedYear = tmdb.year ?? parsed.year;
1302
+ }
1303
+ }
1304
+ }
1305
+ if (detectedType === "tv") {
1306
+ if (parsed.season === void 0) {
1307
+ if (verbose) spinner_default.info(`could not detect season from: ${entry}`);
1308
+ skipped++;
1309
+ continue;
1310
+ }
1311
+ const registeredShow = getShowByTitle(resolvedTitle);
1312
+ let showPath;
1313
+ let showFolderName;
1314
+ if (registeredShow) {
1315
+ showPath = registeredShow.path;
1316
+ showFolderName = showPath.split("/").pop() ?? registeredShow.path;
1317
+ } else if (auto) {
1318
+ showFolderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear);
1319
+ showPath = resolve6(destRoot, showFolderName);
1320
+ if (!dryRun) upsertShow(showPath, tmdbId ?? null, resolvedTitle);
1321
+ } else {
1322
+ if (verbose) spinner_default.info(`not registered, skipped: ${resolvedTitle} \u2014 run: reelsort add "${resolvedTitle}"`);
1323
+ skipped++;
1324
+ continue;
1325
+ }
1326
+ const seasonFolderName = findSeasonFolder2(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1327
+ const seasonPath = resolve6(showPath, seasonFolderName);
1328
+ const videoFile2 = isDir ? findVideo2(entryPath) : entry;
1329
+ if (!videoFile2) {
1330
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1331
+ skipped++;
1332
+ continue;
1333
+ }
1334
+ const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
1335
+ const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1336
+ const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, resolvedTitle, tmdbEpisodeName ?? void 0);
1337
+ const destVideoName2 = `${episodeName}.${videoExt2}`;
1338
+ const destVideoPath = resolve6(seasonPath, destVideoName2);
1339
+ const videoSourcePath2 = isDir ? resolve6(entryPath, videoFile2) : entryPath;
1340
+ if (existsSync7(destVideoPath)) {
1341
+ spinner_default.warn(`already exists: ${episodeName}`);
1342
+ skipped++;
1343
+ continue;
1344
+ }
1345
+ const dirFiles2 = isDir ? readdirSync5(entryPath) : [];
1346
+ const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
1347
+ const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
1348
+ const subtitleSourcePath2 = subtitle2 ? resolve6(entryPath, subtitle2) : null;
1349
+ const destSubtitleName2 = subtitle2 && subtitleExt2 ? `${episodeName}.${subtitleExt2}` : null;
1350
+ if (!dryRun) {
1351
+ mkdirSync4(seasonPath, { recursive: true });
1352
+ let mode = "move";
1353
+ if (useHardlink) {
1354
+ try {
1355
+ if (!sameDev2(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1356
+ linkSync2(videoSourcePath2, destVideoPath);
1357
+ mode = "hardlink";
1358
+ } catch {
1359
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1360
+ cpSync2(videoSourcePath2, destVideoPath);
1361
+ mode = "copy";
1362
+ }
1363
+ if (subtitleSourcePath2 && destSubtitleName2) cpSync2(subtitleSourcePath2, resolve6(seasonPath, destSubtitleName2));
1364
+ } else {
1365
+ if (sameDev2(videoSourcePath2, seasonPath)) {
1366
+ renameSync2(videoSourcePath2, destVideoPath);
1367
+ } else {
1368
+ cpSync2(videoSourcePath2, destVideoPath);
1369
+ rmSync3(videoSourcePath2);
1370
+ }
1371
+ if (subtitleSourcePath2 && destSubtitleName2) renameSync2(subtitleSourcePath2, resolve6(seasonPath, destSubtitleName2));
1372
+ if (isDir) rmSync3(entryPath, { recursive: true, force: true });
1373
+ }
1374
+ recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
1375
+ }
1376
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${showFolderName} / ${seasonFolderName} / ${episodeName}`);
1377
+ imported++;
1378
+ continue;
1379
+ }
1380
+ const edition = detectEdition(entry);
1381
+ const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1382
+ const destFolder = resolve6(destRoot, folderName);
1383
+ if (existsSync7(destFolder)) {
1384
+ spinner_default.warn(`already exists: ${folderName}`);
1385
+ skipped++;
1386
+ continue;
1387
+ }
1388
+ const videoFile = isDir ? findVideo2(entryPath) : entry;
1389
+ if (!videoFile) {
1390
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1391
+ skipped++;
1392
+ continue;
1393
+ }
1394
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1395
+ const destVideoName = `${folderName}.${videoExt}`;
1396
+ const videoSourcePath = isDir ? resolve6(entryPath, videoFile) : entryPath;
1397
+ const dirFiles = isDir ? readdirSync5(entryPath) : [];
1398
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1399
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1400
+ const subtitleSourcePath = subtitle ? resolve6(entryPath, subtitle) : null;
1401
+ const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1402
+ if (!dryRun) {
1403
+ if (useHardlink) {
1404
+ mkdirSync4(destFolder, { recursive: true });
1405
+ const destVideoPath = resolve6(destFolder, destVideoName);
1406
+ let mode;
1407
+ try {
1408
+ if (!sameDev2(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1409
+ linkSync2(videoSourcePath, destVideoPath);
1410
+ mode = "hardlink";
1411
+ } catch {
1412
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1413
+ cpSync2(videoSourcePath, destVideoPath);
1414
+ mode = "copy";
1415
+ }
1416
+ if (subtitleSourcePath && destSubtitleName) cpSync2(subtitleSourcePath, resolve6(destFolder, destSubtitleName));
1417
+ recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1418
+ } else {
1419
+ if (isDir) {
1420
+ const keep = new Set([videoFile, subtitle].filter(Boolean));
1421
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) rmSync3(resolve6(entryPath, f), { recursive: true, force: true });
1422
+ renameSync2(videoSourcePath, resolve6(entryPath, destVideoName));
1423
+ if (subtitleSourcePath && destSubtitleName) renameSync2(subtitleSourcePath, resolve6(entryPath, destSubtitleName));
1424
+ moveFolder(entryPath, destFolder);
1425
+ } else {
1426
+ mkdirSync4(destFolder, { recursive: true });
1427
+ const destVideoPath = resolve6(destFolder, destVideoName);
1428
+ if (sameDev2(videoSourcePath, destRoot)) {
1429
+ renameSync2(videoSourcePath, destVideoPath);
1430
+ } else {
1431
+ cpSync2(videoSourcePath, destVideoPath);
1432
+ rmSync3(videoSourcePath);
1433
+ }
1434
+ }
1435
+ recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
1436
+ }
1437
+ }
1438
+ spinner_default.succeed(`${dryRun ? "[dry] " : ""}${folderName}`);
1439
+ imported++;
1440
+ }
1441
+ }
1442
+ spinner_default.succeed(`imported ${imported} items`);
1443
+ if (skipped) spinner_default.info(`skipped ${skipped} items`);
1444
+ spinner_default.stop();
1445
+ };
1446
+ var scan_default = scan;
1447
+
1448
+ // src/actions/history.ts
1449
+ import cosmetic7 from "cosmetic";
1450
+ import { basename as basename2, extname } from "path";
1451
+ var history = async ({ limit, imports }) => {
1452
+ if (imports) {
1453
+ const sessions = getImportHistory(limit ?? 10);
1454
+ if (sessions.length === 0) {
1455
+ console.log("no import history found");
1456
+ return;
1457
+ }
1458
+ for (const session of sessions) {
1459
+ const date = new Date(session.sessionId);
1460
+ const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
1461
+ console.log(`
1462
+ ${cosmetic7.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
1463
+ for (const r of session.records) {
1464
+ const src = basename2(r.sourcePath);
1465
+ const dest = cosmetic7.cyan.encoder(r.destinationPath);
1466
+ const mode = r.mode !== "move" ? ` ${cosmetic7.blue.encoder(`[${r.mode}]`)}` : "";
1467
+ console.log(` ${src} \u2192 ${dest}${mode}`);
1468
+ }
1469
+ }
1470
+ } else {
1471
+ const sessions = getHistory(limit ?? 10);
1472
+ if (sessions.length === 0) {
1473
+ console.log("no history found");
1474
+ return;
1475
+ }
1476
+ for (const session of sessions) {
1477
+ const date = new Date(session.sessionId);
1478
+ const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
1479
+ const folders = session.records.filter((r) => extname(r.newPath) === "");
1480
+ console.log(`
1481
+ ${cosmetic7.yellow.encoder(label)} (${folders.length} item${folders.length !== 1 ? "s" : ""})`);
1482
+ for (const r of folders) {
1483
+ const oldName = basename2(r.oldPath);
1484
+ const newName = basename2(r.newPath);
1485
+ console.log(` ${cosmetic7.blue.encoder(oldName)} \u2192 ${cosmetic7.cyan.encoder(newName)}`);
1486
+ }
1487
+ }
1488
+ }
1489
+ console.log();
1490
+ };
1491
+ var history_default = history;
1492
+
1493
+ // src/actions/undo.ts
1494
+ import cosmetic8 from "cosmetic";
1495
+ import { renameSync as renameSync3 } from "fs";
1496
+ var undo = async (_) => {
1497
+ spinner_default.start();
1498
+ const records = getLastSession();
1499
+ if (records.length === 0) {
1500
+ spinner_default.info("nothing to undo");
1501
+ spinner_default.stop();
1502
+ return;
1503
+ }
1504
+ let undone = 0;
1505
+ for (const record of records) {
1506
+ renameSync3(record.newPath, record.oldPath);
1507
+ spinner_default.succeed(`${cosmetic8.cyan.encoder(record.newPath)} \u2192 ${cosmetic8.blue.encoder(record.oldPath)}`);
1508
+ undone++;
1509
+ }
1510
+ deleteSession(records[0].sessionId);
1511
+ spinner_default.succeed(`undid ${undone} renames`);
1512
+ spinner_default.stop();
1513
+ };
1514
+ var undo_default = undo;
1515
+
1516
+ // src/actions/differences.ts
1517
+ import cosmetic9 from "cosmetic";
1518
+ import { existsSync as existsSync8, readdirSync as readdirSync6 } from "fs";
1519
+ import { resolve as resolve7 } from "path";
1520
+ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
1521
+ let dir1 = rawDir1;
1522
+ let dir2 = rawDir2;
1523
+ spinner_default.text = `checking differences between ${cosmetic9.blue.encoder(dir1)} and ${cosmetic9.blue.encoder(dir2)}`;
1524
+ spinner_default.start();
1525
+ dir1 = resolve7(dir1);
1526
+ dir2 = resolve7(dir2);
1527
+ if (!existsSync8(dir1)) throw new Error(`dir1 ${dir1} does not exist`);
1528
+ if (!existsSync8(dir2)) throw new Error(`dir2 ${dir2} does not exist`);
1529
+ let list1 = readdirSync6(dir1);
1530
+ let list2 = readdirSync6(dir2);
1531
+ if (only && only.length) {
1532
+ list1 = list1.filter((i) => {
1533
+ for (const o of only) if (i.endsWith(o)) return true;
1534
+ return false;
1535
+ });
1536
+ list2 = list2.filter((i) => {
1537
+ for (const o of only) if (i.endsWith(o)) return true;
1538
+ return false;
1539
+ });
1540
+ }
1541
+ if (ignore && ignore.length) {
1542
+ list1 = list1.filter((i) => {
1543
+ for (const o of ignore) if (i.endsWith(o)) return false;
1544
+ return true;
1545
+ });
1546
+ list2 = list2.filter((i) => {
1547
+ for (const o of ignore) if (i.endsWith(o)) return false;
1548
+ return true;
1549
+ });
1550
+ }
1551
+ const added = [], removed = [];
1552
+ for (const l of list1) {
1553
+ if (list2.includes(l)) {
1554
+ added.push(l);
1555
+ } else {
1556
+ removed.push(l);
1557
+ }
1558
+ }
1559
+ spinner_default.succeed(`checked differences between ${cosmetic9.blue.encoder(dir1)} and ${cosmetic9.blue.encoder(dir2)}`);
1560
+ spinner_default.succeed(`found ${added.length} added files`);
1561
+ spinner_default.succeed(`found ${removed.length} removed files`);
1562
+ spinner_default.stop();
1563
+ for (const i of added) console.log(`${cosmetic9.green.encoder("added")} ${i}`);
1564
+ for (const i of removed) console.log(`${cosmetic9.red.encoder("removed")} ${i}`);
1565
+ };
1566
+ var differences_default = differences;
1567
+
1568
+ // src/actions/rename.ts
1569
+ import cosmetic10 from "cosmetic";
1570
+ import { existsSync as existsSync9, lstatSync as lstatSync5, readdirSync as readdirSync7, renameSync as renameSync4 } from "fs";
1571
+ import { resolve as resolve8 } from "path";
1572
+ import { rimraf } from "rimraf";
1573
+ var rename = async ({ dir: inputDir, type, verbose }) => {
1574
+ const dir = resolve8(inputDir);
1575
+ const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1576
+ const config = getConfig();
1577
+ const language = config.language ?? "eng";
1578
+ const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
1579
+ spinner_default.text = `renaming in ${cosmetic10.blue.encoder(dir)}`;
1580
+ spinner_default.start();
1581
+ if (!existsSync9(dir)) throw new Error(`dir ${dir} does not exist`);
1582
+ const list2 = readdirSync7(dir);
1583
+ let renamed = 0, removed = 0, skipped = 0;
1584
+ for (const [index, entry] of list2.entries()) {
1585
+ spinner_default.text = `renaming in ${cosmetic10.blue.encoder(dir)} ${index + 1}/${list2.length}`;
1586
+ if (!lstatSync5(resolve8(dir, entry)).isDirectory()) {
1587
+ if (verbose) spinner_default.info(`skipped ${entry}`);
1588
+ skipped++;
1589
+ continue;
1590
+ }
1591
+ const isPs3Candidate = /(?<=\[).+?(?=\])/.test(entry);
1592
+ const usePs3 = type === "ps3" || !type && isPs3Candidate;
1593
+ if (usePs3) {
1594
+ const nameMatch = entry.match(/(?<=\[).+?(?=\])/);
1595
+ const id = entry.split("-")[0];
1596
+ if (!nameMatch || !id) {
1597
+ if (verbose) spinner_default.info(`skipped ${entry}`);
1598
+ skipped++;
1599
+ continue;
1600
+ }
1601
+ const ps3Old = resolve8(dir, entry);
1602
+ const ps3New = resolve8(dir, `${nameMatch[0]} [${id}]`);
1603
+ renameSync4(ps3Old, ps3New);
1604
+ recordRename(sessionId, ps3Old, ps3New);
1605
+ spinner_default.succeed(`${nameMatch[0]} [${id}]`);
1606
+ renamed++;
1607
+ continue;
1608
+ }
1609
+ const yearMatch = entry.match(/\([^\d]*(\d+)[^\d]*\)/);
1610
+ if (!yearMatch) {
1611
+ if (verbose) spinner_default.info(`skipped ${entry}`);
1612
+ skipped++;
1613
+ continue;
1614
+ }
1615
+ const year = yearMatch[0];
1616
+ if (year.length !== 6) {
1617
+ if (verbose) spinner_default.info(`skipped ${entry}`);
1618
+ skipped++;
1619
+ continue;
1620
+ }
1621
+ const title = titleCase_default(entry.substring(0, entry.indexOf(year)).trim());
1622
+ const sublist = readdirSync7(resolve8(dir, entry));
1623
+ const video = sublist.find((f) => {
1624
+ const ext2 = f.match(/([^.]+$)/)?.[0];
1625
+ return videoExtensions_default.includes(ext2) && title.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
1626
+ });
1627
+ if (!video) {
1628
+ if (verbose) spinner_default.info(`skipped ${entry}`);
1629
+ skipped++;
1630
+ continue;
1631
+ }
1632
+ const ext = video.match(/([^.]+$)/)?.[0];
1633
+ if (!ext) {
1634
+ if (verbose) spinner_default.info(`skipped ${entry}`);
1635
+ skipped++;
1636
+ continue;
1637
+ }
1638
+ const yearNum = parseInt(year.replace(/\D/g, ""));
1639
+ const formatted = formatMovieName(movieFormat, title, yearNum);
1640
+ if (entry === formatted && video === `${formatted}.${ext}`) {
1641
+ if (verbose) spinner_default.info(`skipped ${entry}`);
1642
+ skipped++;
1643
+ continue;
1644
+ }
1645
+ const subtitle = findSubtitle(sublist, language);
1646
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1647
+ const keep = new Set([video, subtitle].filter(Boolean));
1648
+ const others = sublist.filter((f) => !keep.has(f));
1649
+ for (const f of others) {
1650
+ await rimraf(resolve8(dir, entry, f));
1651
+ removed++;
1652
+ }
1653
+ const fileOld = resolve8(dir, entry, video);
1654
+ const fileNew = resolve8(dir, entry, `${formatted}.${ext}`);
1655
+ const folderOld = resolve8(dir, entry);
1656
+ const folderNew = resolve8(dir, formatted);
1657
+ renameSync4(fileOld, fileNew);
1658
+ if (subtitle && subtitleExt) {
1659
+ renameSync4(resolve8(dir, entry, subtitle), resolve8(dir, entry, `${formatted}.${subtitleExt}`));
1660
+ }
1661
+ renameSync4(folderOld, folderNew);
1662
+ recordRename(sessionId, fileOld, fileNew);
1663
+ recordRename(sessionId, folderOld, folderNew);
1664
+ spinner_default.succeed(formatted);
1665
+ renamed++;
1666
+ }
1667
+ spinner_default.succeed(`renamed ${renamed} files`);
1668
+ if (removed) spinner_default.info(`removed ${removed} files`);
1669
+ spinner_default.info(`skipped ${skipped} files`);
1670
+ spinner_default.succeed(`done in ${cosmetic10.cyan.encoder(dir)}`);
1671
+ spinner_default.stop();
1672
+ };
1673
+ var rename_default = rename;
1674
+
1675
+ // src/actions/reset.ts
1676
+ import cosmetic11 from "cosmetic";
1677
+ import { existsSync as existsSync10, readdirSync as readdirSync8, renameSync as renameSync5 } from "fs";
1678
+ import { basename as basename3, dirname as dirname3, resolve as resolve9, sep } from "path";
1679
+ var reset = async ({ dir: inputDir, double }) => {
1680
+ let dir = inputDir;
1681
+ spinner_default.text = `resetting episodes in ${cosmetic11.blue.encoder(dir)}`;
1682
+ spinner_default.start();
1683
+ dir = resolve9(dir);
1684
+ if (!existsSync10(dir)) throw new Error(`dir ${dir} does not exist`);
1685
+ const list2 = readdirSync8(dir).sort();
1686
+ const folder = dir.replace(/\./g, " ").split(sep).pop();
1687
+ let season;
1688
+ let sub = folder.includes("season") ? folder.substring(folder.indexOf("season") + "season".length, folder.length).trim() : /s\d/i.test(folder) ? folder.substring(folder.search(/s\d/i) + 1, folder.length).trim() : null;
1689
+ if (sub)
1690
+ while (sub.search(/[0-9]/) === 0) {
1691
+ season = `${season || ""}${sub.substring(0, 1)}`;
1692
+ sub = sub.substring(1, sub.length);
1693
+ }
1694
+ const seasonNum = parseInt(season);
1695
+ if (!seasonNum) throw new Error(`unable to identify season number`);
1696
+ const parentFolder = basename3(dirname3(dir));
1697
+ const showTitle = parentFolder.match(/^(.+?)\s*(?:\(\d{4}\))?$/)?.[1]?.trim() || void 0;
1698
+ spinner_default.info(`identified as season ${seasonNum}${showTitle ? ` of ${showTitle}` : ""}`);
1699
+ const sublist = list2.filter((f) => {
1700
+ const ext = f.match(/([^.]+$)/)?.[0];
1701
+ return videoExtensions_default.includes(ext) && f.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
1702
+ });
1703
+ const other = list2.filter((f) => {
1704
+ const ext = f.match(/([^.]+$)/)?.[0];
1705
+ return !videoExtensions_default.includes(ext) && f.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
1706
+ });
1707
+ const episodeFormat = getConfig().format?.episode;
1708
+ let renamed = 0, skipped = other.length;
1709
+ for (const [index, i] of sublist.entries()) {
1710
+ spinner_default.text = `resetting episodes in ${cosmetic11.blue.encoder(dir)} ${index}/${list2.length}`;
1711
+ const ext = i.match(/([^.]+$)/)?.[0];
1712
+ const episode = double ? index * 2 + 1 : index + 1;
1713
+ const name = `${formatEpisode(seasonNum, episode, episodeFormat, double, showTitle)}.${ext}`;
1714
+ if (i === name) {
1715
+ skipped++;
1716
+ continue;
1717
+ }
1718
+ renameSync5(resolve9(dir, i), resolve9(dir, name));
1719
+ renamed++;
1720
+ }
1721
+ spinner_default.succeed(`renamed ${renamed} files`);
1722
+ spinner_default.info(`skipped ${skipped} files`);
1723
+ spinner_default.succeed(`done in ${cosmetic11.cyan.encoder(dir)}`);
1724
+ spinner_default.stop();
1725
+ };
1726
+ var reset_default = reset;
1727
+ export {
1728
+ DEFAULT_EPISODE_FORMAT,
1729
+ DEFAULT_MOVIE_FORMAT,
1730
+ DEFAULT_SEASON_FORMAT,
1731
+ clean_default as clean,
1732
+ configAdd,
1733
+ configRemove,
1734
+ configSet,
1735
+ configShow,
1736
+ deleteImport,
1737
+ deleteSession,
1738
+ detectEdition,
1739
+ differences_default as differences,
1740
+ formatEpisode,
1741
+ formatMovieName,
1742
+ formatSeasonFolder,
1743
+ getCleanableImports,
1744
+ getConfig,
1745
+ getHistory,
1746
+ getImportByDest,
1747
+ getLastSession,
1748
+ getMediaInfo,
1749
+ history_default as history,
1750
+ list_default as list,
1751
+ normalizeCodec,
1752
+ normalizeResolution,
1753
+ parseDownloadName,
1754
+ parseQuality,
1755
+ probe_default as probe,
1756
+ recordImport,
1757
+ recordRename,
1758
+ rename_default as rename,
1759
+ reset_default as reset,
1760
+ saveConfig,
1761
+ scan_default as scan,
1762
+ titleCase_default as titleCase,
1763
+ undo_default as undo,
1764
+ upsertMediaInfo,
1765
+ videoExtensions_default as videoExtensions,
1766
+ watch_default as watch
1767
+ };