reelsort 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,49 +1,133 @@
1
- // src/actions/config.ts
1
+ // src/actions/clean.ts
2
2
  import cosmetic from "cosmetic";
3
- import { resolve } from "path";
3
+ import { existsSync as existsSync2, rmSync } from "fs";
4
4
 
5
- // src/config.ts
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ // src/db.ts
6
+ import Database from "better-sqlite3";
7
+ import { existsSync, mkdirSync } from "fs";
7
8
  import { homedir } from "os";
8
9
  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"));
10
+ var DB_DIR = join(homedir(), ".config", "reelsort");
11
+ var DB_PATH = join(DB_DIR, "reelsort.db");
12
+ var _db = null;
13
+ var db = () => {
14
+ if (_db) return _db;
15
+ if (!existsSync(DB_DIR)) mkdirSync(DB_DIR, { recursive: true });
16
+ _db = new Database(DB_PATH);
17
+ _db.exec(`
18
+ CREATE TABLE IF NOT EXISTS shows (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ path TEXT NOT NULL UNIQUE,
21
+ tmdbId INTEGER,
22
+ title TEXT,
23
+ ended INTEGER NOT NULL DEFAULT 0,
24
+ addedAt DATETIME DEFAULT CURRENT_TIMESTAMP
25
+ );
26
+ CREATE TABLE IF NOT EXISTS renameHistory (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ sessionId TEXT NOT NULL,
29
+ oldPath TEXT NOT NULL,
30
+ newPath TEXT NOT NULL,
31
+ renamedAt DATETIME DEFAULT CURRENT_TIMESTAMP
32
+ );
33
+ CREATE TABLE IF NOT EXISTS mediaInfo (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ filePath TEXT NOT NULL UNIQUE,
36
+ codec TEXT,
37
+ resolution TEXT,
38
+ width INTEGER,
39
+ height INTEGER,
40
+ duration REAL,
41
+ probedAt DATETIME DEFAULT CURRENT_TIMESTAMP
42
+ );
43
+ CREATE TABLE IF NOT EXISTS imports (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ sessionId TEXT NOT NULL,
46
+ sourcePath TEXT NOT NULL,
47
+ destinationPath TEXT NOT NULL,
48
+ mode TEXT NOT NULL,
49
+ tmdbId INTEGER,
50
+ importedAt DATETIME DEFAULT CURRENT_TIMESTAMP
51
+ )
52
+ `);
53
+ try {
54
+ _db.exec("ALTER TABLE shows ADD COLUMN title TEXT");
55
+ } catch {
56
+ }
57
+ return _db;
14
58
  };
15
- var saveConfig = (config) => {
16
- if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
17
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
59
+ var recordRename = (sessionId, oldPath, newPath) => {
60
+ db().prepare("INSERT INTO renameHistory (sessionId, oldPath, newPath) VALUES (?, ?, ?)").run(sessionId, oldPath, newPath);
18
61
  };
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();
62
+ var getLastSession = () => {
63
+ const last = db().prepare("SELECT sessionId FROM renameHistory ORDER BY id DESC LIMIT 1").get();
64
+ if (!last) return [];
65
+ return db().prepare("SELECT * FROM renameHistory WHERE sessionId = ? ORDER BY id DESC").all(last.sessionId);
25
66
  };
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);
67
+ var deleteSession = (sessionId) => {
68
+ db().prepare("DELETE FROM renameHistory WHERE sessionId = ?").run(sessionId);
34
69
  };
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();
70
+ var recordImport = (sessionId, sourcePath, destPath, mode, tmdbId) => {
71
+ db().prepare("INSERT INTO imports (sessionId, sourcePath, destinationPath, mode, tmdbId) VALUES (?, ?, ?, ?, ?)").run(sessionId, sourcePath, destPath, mode, tmdbId ?? null);
72
+ };
73
+ var getMediaInfo = (filePath) => {
74
+ return db().prepare("SELECT * FROM mediaInfo WHERE filePath = ?").get(filePath);
75
+ };
76
+ var upsertMediaInfo = (filePath, codec, resolution, width, height, duration) => {
77
+ db().prepare(
78
+ `INSERT INTO mediaInfo (filePath, codec, resolution, width, height, duration, probedAt)
79
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
80
+ ON CONFLICT(filePath) DO UPDATE SET
81
+ codec = excluded.codec, resolution = excluded.resolution,
82
+ width = excluded.width, height = excluded.height,
83
+ duration = excluded.duration, probedAt = excluded.probedAt`
84
+ ).run(filePath, codec, resolution, width, height, duration);
85
+ };
86
+ var getImportByDest = (destPath) => {
87
+ return db().prepare("SELECT * FROM imports WHERE destinationPath = ? LIMIT 1").get(destPath);
88
+ };
89
+ var rowToShow = (row) => ({
90
+ ...row,
91
+ ended: row.ended === 1
92
+ });
93
+ var getShows = () => db().prepare("SELECT * FROM shows ORDER BY path ASC").all().map(rowToShow);
94
+ var upsertShow = (path, tmdbId, title) => {
95
+ db().prepare(
96
+ `
97
+ INSERT INTO shows (path, tmdbId, title) VALUES (?, ?, ?)
98
+ ON CONFLICT(path) DO UPDATE SET
99
+ tmdbId = COALESCE(excluded.tmdbId, tmdbId),
100
+ title = COALESCE(excluded.title, title)
101
+ `
102
+ ).run(path, tmdbId, title ?? null);
103
+ };
104
+ var getShowByTitle = (title) => {
105
+ const t = title.toLowerCase();
106
+ return getShows().find((s) => {
107
+ if (s.title?.toLowerCase() === t) return true;
108
+ const folderTitle = (s.path.split("/").pop() ?? "").replace(/\s*\(\d{4}\).*$/, "").trim().toLowerCase();
109
+ return folderTitle === t;
110
+ }) ?? null;
111
+ };
112
+ var getCleanableImports = () => {
113
+ return db().prepare(`SELECT * FROM imports WHERE mode IN ('hardlink', 'copy') ORDER BY importedAt ASC`).all();
114
+ };
115
+ var deleteImport = (id) => {
116
+ db().prepare("DELETE FROM imports WHERE id = ?").run(id);
117
+ };
118
+ var getImportHistory = (limit = 10) => {
119
+ const sessionIds = db().prepare("SELECT sessionId FROM imports GROUP BY sessionId ORDER BY MAX(id) DESC LIMIT ?").all(limit);
120
+ return sessionIds.map(({ sessionId }) => ({
121
+ sessionId,
122
+ records: db().prepare("SELECT * FROM imports WHERE sessionId = ? ORDER BY id ASC").all(sessionId)
123
+ }));
124
+ };
125
+ var getHistory = (limit = 10) => {
126
+ const sessionIds = db().prepare("SELECT sessionId FROM renameHistory GROUP BY sessionId ORDER BY MAX(id) DESC LIMIT ?").all(limit);
127
+ return sessionIds.map(({ sessionId }) => ({
128
+ sessionId,
129
+ records: db().prepare("SELECT * FROM renameHistory WHERE sessionId = ? ORDER BY id DESC").all(sessionId)
130
+ }));
47
131
  };
48
132
 
49
133
  // src/refs/spinner.ts
@@ -96,6 +180,107 @@ var Spinner = class {
96
180
  };
97
181
  var spinner_default = new Spinner();
98
182
 
183
+ // src/actions/clean.ts
184
+ var parseOlderThan = (s) => {
185
+ const match = s.match(/^(\d+)([dhm])$/);
186
+ if (!match) return null;
187
+ const n = parseInt(match[1]);
188
+ const unit = match[2];
189
+ const ms = unit === "d" ? n * 864e5 : unit === "h" ? n * 36e5 : n * 6e4;
190
+ return ms;
191
+ };
192
+ var clean = async ({ dryRun, olderThan }) => {
193
+ spinner_default.start();
194
+ const imports = getCleanableImports();
195
+ if (imports.length === 0) {
196
+ spinner_default.info("nothing to clean");
197
+ spinner_default.stop();
198
+ return;
199
+ }
200
+ const cutoffMs = olderThan ? parseOlderThan(olderThan) : null;
201
+ if (olderThan && cutoffMs === null) throw new Error(`invalid --older-than format, expected e.g. 14d, 6h, 30m`);
202
+ let cleaned = 0, skipped = 0;
203
+ for (const imp of imports) {
204
+ if (cutoffMs !== null) {
205
+ const age = Date.now() - new Date(imp.importedAt).getTime();
206
+ if (age < cutoffMs) {
207
+ skipped++;
208
+ continue;
209
+ }
210
+ }
211
+ if (!existsSync2(imp.sourcePath)) {
212
+ if (!dryRun) deleteImport(imp.id);
213
+ continue;
214
+ }
215
+ if (dryRun) {
216
+ spinner_default.succeed(`[dry] would remove ${cosmetic.blue.encoder(imp.sourcePath)}`);
217
+ cleaned++;
218
+ continue;
219
+ }
220
+ try {
221
+ rmSync(imp.sourcePath, { recursive: true, force: true });
222
+ deleteImport(imp.id);
223
+ spinner_default.succeed(`removed ${cosmetic.blue.encoder(imp.sourcePath)}`);
224
+ cleaned++;
225
+ } catch {
226
+ spinner_default.warn(`locked or inaccessible, skipped: ${cosmetic.blue.encoder(imp.sourcePath)}`);
227
+ skipped++;
228
+ }
229
+ }
230
+ spinner_default.succeed(`cleaned ${cleaned} items`);
231
+ if (skipped) spinner_default.info(`skipped ${skipped} items`);
232
+ spinner_default.stop();
233
+ };
234
+ var clean_default = clean;
235
+
236
+ // src/actions/config.ts
237
+ import cosmetic2 from "cosmetic";
238
+ import { resolve } from "path";
239
+
240
+ // src/config.ts
241
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
242
+ import { homedir as homedir2 } from "os";
243
+ import { join as join2 } from "path";
244
+ var CONFIG_DIR = join2(homedir2(), ".config", "reelsort");
245
+ var CONFIG_PATH = join2(CONFIG_DIR, "config.json");
246
+ var getConfig = () => {
247
+ if (!existsSync3(CONFIG_PATH)) return { sources: [], dest: {} };
248
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
249
+ };
250
+ var saveConfig = (config) => {
251
+ if (!existsSync3(CONFIG_DIR)) mkdirSync2(CONFIG_DIR, { recursive: true });
252
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
253
+ };
254
+
255
+ // src/helpers/formatEpisode.ts
256
+ var DEFAULT_EPISODE_FORMAT = "{s}x{ee}";
257
+ var DEFAULT_SEASON_FORMAT = "Season {s}";
258
+ var renderEpisode = (format, season, episode, title, name) => {
259
+ 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();
260
+ };
261
+ 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();
262
+ var formatEpisode = (season, episode, format = DEFAULT_EPISODE_FORMAT, double = false, title, name) => {
263
+ if (double) {
264
+ const a = renderEpisode(format, season, episode, title, name);
265
+ const b = renderEpisode(format, season, episode + 1, title, name);
266
+ return `${a}-${b}`;
267
+ }
268
+ return renderEpisode(format, season, episode, title, name);
269
+ };
270
+
271
+ // src/helpers/formatName.ts
272
+ var DEFAULT_MOVIE_FORMAT = "{title} ({year})";
273
+ var formatMovieName = (template, title, year, edition) => {
274
+ const editionTag = edition ? ` {edition-${edition}}` : "";
275
+ let result = template.replace("{title}", title).replace("{edition}", editionTag);
276
+ if (year) {
277
+ result = result.replace("{year}", year.toString());
278
+ } else {
279
+ result = result.replace(/\s*[([{]\{year\}[)\]}]/g, "").replace("{year}", "");
280
+ }
281
+ return result.replace(/\s+/g, " ").trim();
282
+ };
283
+
99
284
  // src/actions/config.ts
100
285
  var DEST_TYPES = ["movie", "tv", "ps3"];
101
286
  var configAdd = async ({ key, value }) => {
@@ -104,14 +289,14 @@ var configAdd = async ({ key, value }) => {
104
289
  const config = getConfig();
105
290
  if (config.sources.includes(dir)) {
106
291
  spinner_default.start();
107
- spinner_default.info(`source already configured: ${cosmetic.blue.encoder(dir)}`);
292
+ spinner_default.info(`source already configured: ${cosmetic2.blue.encoder(dir)}`);
108
293
  spinner_default.stop();
109
294
  return;
110
295
  }
111
296
  config.sources.push(dir);
112
297
  saveConfig(config);
113
298
  spinner_default.start();
114
- spinner_default.succeed(`added source: ${cosmetic.blue.encoder(dir)}`);
299
+ spinner_default.succeed(`added source: ${cosmetic2.blue.encoder(dir)}`);
115
300
  spinner_default.stop();
116
301
  };
117
302
  var configRemove = async ({ key, value }) => {
@@ -121,14 +306,14 @@ var configRemove = async ({ key, value }) => {
121
306
  const index = config.sources.indexOf(dir);
122
307
  if (index === -1) {
123
308
  spinner_default.start();
124
- spinner_default.warn(`source not found: ${cosmetic.blue.encoder(dir)}`);
309
+ spinner_default.warn(`source not found: ${cosmetic2.blue.encoder(dir)}`);
125
310
  spinner_default.stop();
126
311
  return;
127
312
  }
128
313
  config.sources.splice(index, 1);
129
314
  saveConfig(config);
130
315
  spinner_default.start();
131
- spinner_default.succeed(`removed source: ${cosmetic.blue.encoder(dir)}`);
316
+ spinner_default.succeed(`removed source: ${cosmetic2.blue.encoder(dir)}`);
132
317
  spinner_default.stop();
133
318
  };
134
319
  var configSet = async ({ key, subkey, value }) => {
@@ -137,7 +322,7 @@ var configSet = async ({ key, subkey, value }) => {
137
322
  config.language = subkey;
138
323
  saveConfig(config);
139
324
  spinner_default.start();
140
- spinner_default.succeed(`set subtitle language: ${cosmetic.cyan.encoder(subkey)}`);
325
+ spinner_default.succeed(`set subtitle language: ${cosmetic2.cyan.encoder(subkey)}`);
141
326
  spinner_default.stop();
142
327
  return;
143
328
  }
@@ -167,7 +352,7 @@ var configSet = async ({ key, subkey, value }) => {
167
352
  }
168
353
  saveConfig(config);
169
354
  spinner_default.start();
170
- spinner_default.succeed(`set ${subkey} format: ${cosmetic.cyan.encoder(value ?? subkey)}`);
355
+ spinner_default.succeed(`set ${subkey} format: ${cosmetic2.cyan.encoder(value ?? subkey)}`);
171
356
  spinner_default.stop();
172
357
  return;
173
358
  }
@@ -180,16 +365,16 @@ var configSet = async ({ key, subkey, value }) => {
180
365
  config.dest[subkey] = dir;
181
366
  saveConfig(config);
182
367
  spinner_default.start();
183
- spinner_default.succeed(`set ${subkey} destination: ${cosmetic.cyan.encoder(dir)}`);
368
+ spinner_default.succeed(`set ${subkey} destination: ${cosmetic2.cyan.encoder(dir)}`);
184
369
  spinner_default.stop();
185
370
  };
186
- var configShow = async (_) => {
371
+ var configShow = async () => {
187
372
  const config = getConfig();
188
373
  console.log("\nSources:");
189
374
  if (config.sources.length === 0) {
190
375
  console.log(" (none)");
191
376
  } else {
192
- for (const s of config.sources) console.log(` ${cosmetic.blue.encoder(s)}`);
377
+ for (const s of config.sources) console.log(` ${cosmetic2.blue.encoder(s)}`);
193
378
  }
194
379
  console.log("\nDestinations:");
195
380
  const entries = DEST_TYPES.map((t) => ({ type: t, path: config.dest[t] })).filter((e) => e.path);
@@ -197,147 +382,185 @@ var configShow = async (_) => {
197
382
  console.log(" (none)");
198
383
  } else {
199
384
  for (const { type, path } of entries) {
200
- console.log(` ${type.padEnd(6)} ${cosmetic.cyan.encoder(path)}`);
385
+ console.log(` ${type.padEnd(6)} ${cosmetic2.cyan.encoder(path)}`);
201
386
  }
202
387
  }
203
388
  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)")}`);
389
+ Subtitle language: ${cosmetic2.cyan.encoder(config.language ?? "eng (default)")}`);
390
+ console.log(`TMDb API key: ${config.tmdbApiKey ? cosmetic2.green.encoder("configured") : cosmetic2.red.encoder("not set")}`);
391
+ console.log(`Movie format: ${cosmetic2.cyan.encoder(config.format?.movie ?? DEFAULT_MOVIE_FORMAT + " (default)")}`);
392
+ console.log(`Episode format: ${cosmetic2.cyan.encoder(config.format?.episode ?? DEFAULT_EPISODE_FORMAT + " (default)")}`);
393
+ console.log(`Season folder: ${cosmetic2.cyan.encoder(config.format?.season ?? DEFAULT_SEASON_FORMAT + " (default)")}`);
209
394
  console.log();
210
395
  };
211
396
 
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";
397
+ // src/actions/differences.ts
398
+ import cosmetic3 from "cosmetic";
399
+ import { existsSync as existsSync4, readdirSync } from "fs";
216
400
  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 {
401
+ var differences = async ({ dir1: rawDir1, dir2: rawDir2, only, ignore }) => {
402
+ let dir1 = rawDir1;
403
+ let dir2 = rawDir2;
404
+ spinner_default.text = `checking differences between ${cosmetic3.blue.encoder(dir1)} and ${cosmetic3.blue.encoder(dir2)}`;
405
+ spinner_default.start();
406
+ dir1 = resolve2(dir1);
407
+ dir2 = resolve2(dir2);
408
+ if (!existsSync4(dir1)) throw new Error(`dir1 ${dir1} does not exist`);
409
+ if (!existsSync4(dir2)) throw new Error(`dir2 ${dir2} does not exist`);
410
+ let list1 = readdirSync(dir1);
411
+ let list2 = readdirSync(dir2);
412
+ if (only && only.length) {
413
+ list1 = list1.filter((i) => {
414
+ for (const o of only) if (i.endsWith(o)) return true;
415
+ return false;
416
+ });
417
+ list2 = list2.filter((i) => {
418
+ for (const o of only) if (i.endsWith(o)) return true;
419
+ return false;
420
+ });
269
421
  }
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);
422
+ if (ignore && ignore.length) {
423
+ list1 = list1.filter((i) => {
424
+ for (const o of ignore) if (i.endsWith(o)) return false;
425
+ return true;
426
+ });
427
+ list2 = list2.filter((i) => {
428
+ for (const o of ignore) if (i.endsWith(o)) return false;
429
+ return true;
430
+ });
431
+ }
432
+ const added = [], removed = [];
433
+ for (const l of list1) {
434
+ if (list2.includes(l)) {
435
+ added.push(l);
436
+ } else {
437
+ removed.push(l);
438
+ }
439
+ }
440
+ spinner_default.succeed(`checked differences between ${cosmetic3.blue.encoder(dir1)} and ${cosmetic3.blue.encoder(dir2)}`);
441
+ spinner_default.succeed(`found ${added.length} added files`);
442
+ spinner_default.succeed(`found ${removed.length} removed files`);
443
+ spinner_default.stop();
444
+ for (const i of added) console.log(`${cosmetic3.green.encoder("added")} ${i}`);
445
+ for (const i of removed) console.log(`${cosmetic3.red.encoder("removed")} ${i}`);
299
446
  };
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);
447
+ var differences_default = differences;
448
+
449
+ // src/actions/history.ts
450
+ import cosmetic4 from "cosmetic";
451
+ import { basename, extname } from "path";
452
+ var history = async ({ limit, imports }) => {
453
+ if (imports) {
454
+ const sessions = getImportHistory(limit ?? 10);
455
+ if (sessions.length === 0) {
456
+ console.log("no import history found");
457
+ return;
458
+ }
459
+ for (const session of sessions) {
460
+ const date = new Date(session.sessionId);
461
+ const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
462
+ console.log(`
463
+ ${cosmetic4.yellow.encoder(label)} (${session.records.length} item${session.records.length !== 1 ? "s" : ""})`);
464
+ for (const r of session.records) {
465
+ const src = basename(r.sourcePath);
466
+ const dest = cosmetic4.cyan.encoder(r.destinationPath);
467
+ const mode = r.mode !== "move" ? ` ${cosmetic4.blue.encoder(`[${r.mode}]`)}` : "";
468
+ console.log(` ${src} \u2192 ${dest}${mode}`);
469
+ }
470
+ }
471
+ } else {
472
+ const sessions = getHistory(limit ?? 10);
473
+ if (sessions.length === 0) {
474
+ console.log("no history found");
475
+ return;
476
+ }
477
+ for (const session of sessions) {
478
+ const date = new Date(session.sessionId);
479
+ const label = isNaN(date.getTime()) ? session.sessionId : date.toLocaleString();
480
+ const folders = session.records.filter((r) => extname(r.newPath) === "");
481
+ console.log(`
482
+ ${cosmetic4.yellow.encoder(label)} (${folders.length} item${folders.length !== 1 ? "s" : ""})`);
483
+ for (const r of folders) {
484
+ const oldName = basename(r.oldPath);
485
+ const newName = basename(r.newPath);
486
+ console.log(` ${cosmetic4.blue.encoder(oldName)} \u2192 ${cosmetic4.cyan.encoder(newName)}`);
487
+ }
488
+ }
489
+ }
490
+ console.log();
312
491
  };
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;
492
+ var history_default = history;
493
+
494
+ // src/actions/list.ts
495
+ import cosmetic5 from "cosmetic";
496
+ import { existsSync as existsSync5, lstatSync, readdirSync as readdirSync3 } from "fs";
497
+ import { resolve as resolve4 } from "path";
498
+
499
+ // src/helpers/dirSize.ts
500
+ import { readdirSync as readdirSync2, statSync } from "fs";
501
+ import { resolve as resolve3 } from "path";
502
+ var dirSize = (dir) => {
503
+ let total = 0;
504
+ try {
505
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
506
+ const full = resolve3(dir, entry.name);
507
+ if (entry.isDirectory()) {
508
+ total += dirSize(full);
509
+ } else if (entry.isFile()) {
510
+ try {
511
+ total += statSync(full).size;
512
+ } catch {
513
+ }
514
+ }
515
+ }
516
+ } catch {
517
+ }
518
+ return total;
320
519
  };
321
- var getCleanableImports = () => {
322
- return db().prepare(`SELECT * FROM imports WHERE mode IN ('hardlink', 'copy') ORDER BY importedAt ASC`).all();
520
+ var formatSize = (bytes) => {
521
+ if (bytes >= 1099511627776) return `${(bytes / 1099511627776).toFixed(1)} TB`;
522
+ if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
523
+ if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
524
+ return `${(bytes / 1024).toFixed(1)} KB`;
323
525
  };
324
- var deleteImport = (id) => {
325
- db().prepare("DELETE FROM imports WHERE id = ?").run(id);
526
+
527
+ // src/helpers/parseQuality.ts
528
+ var RESOLUTION_MAP = {
529
+ "480p": "480p",
530
+ "576p": "576p",
531
+ "720p": "720p",
532
+ "1080p": "1080p",
533
+ "2160p": "2160p",
534
+ "4k": "2160p",
535
+ uhd: "2160p",
536
+ "8k": "8K"
326
537
  };
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
- }));
538
+ var CODEC_MAP = {
539
+ x264: "x264",
540
+ h264: "x264",
541
+ avc: "x264",
542
+ x265: "x265",
543
+ h265: "x265",
544
+ hevc: "x265",
545
+ xvid: "XviD",
546
+ divx: "DivX"
333
547
  };
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
- }));
548
+ var parseQuality = (filename) => {
549
+ const tokens = filename.toLowerCase().split(/[.\s_\-/\\]+/);
550
+ let resolution;
551
+ let codec;
552
+ for (const token of tokens) {
553
+ if (!resolution && RESOLUTION_MAP[token]) resolution = RESOLUTION_MAP[token];
554
+ if (!codec && CODEC_MAP[token]) codec = CODEC_MAP[token];
555
+ if (resolution && codec) break;
556
+ }
557
+ return { resolution, codec };
340
558
  };
559
+ var normalizeResolution = (s) => RESOLUTION_MAP[s.toLowerCase()] ?? s;
560
+ var normalizeCodec = (s) => CODEC_MAP[s.toLowerCase()] ?? s;
561
+
562
+ // src/refs/subtitleExtensions.json
563
+ var subtitleExtensions_default = ["srt", "sub", "idx", "ass", "ssa", "vtt", "sup"];
341
564
 
342
565
  // src/refs/videoExtensions.json
343
566
  var videoExtensions_default = [
@@ -376,183 +599,9 @@ var videoExtensions_default = [
376
599
  "yuv"
377
600
  ];
378
601
 
379
- // src/actions/probe.ts
602
+ // src/actions/list.ts
380
603
  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) => {
604
+ var findVideoFile = (dir) => {
556
605
  try {
557
606
  return readdirSync3(dir).find((f) => {
558
607
  const ext = f.match(/([^.]+$)/)?.[0]?.toLowerCase();
@@ -580,18 +629,18 @@ var parseLibraryFolder = (name) => {
580
629
  var col = (s, width) => s.padEnd(width).substring(0, width);
581
630
  var list = async ({ type, missingSubs, codec: codecFilter, resolution: resFilter, sort }) => {
582
631
  const config = getConfig();
583
- const types = (type ? [type] : DEST_TYPES3).filter((t) => config.dest[t]);
632
+ const types = (type ? [type] : DEST_TYPES2).filter((t) => config.dest[t]);
584
633
  if (types.length === 0) throw new Error("no destinations configured \u2014 run: reelsort config set dest movie <dir>");
585
634
  for (const t of types) {
586
635
  const destRoot = config.dest[t];
587
- if (!existsSync4(destRoot)) {
636
+ if (!existsSync5(destRoot)) {
588
637
  console.log(`
589
- ${t.toUpperCase()} ${cosmetic3.blue.encoder(destRoot)} (not found)`);
638
+ ${t.toUpperCase()} ${cosmetic5.blue.encoder(destRoot)} (not found)`);
590
639
  continue;
591
640
  }
592
641
  const folders = readdirSync3(destRoot).filter((f) => {
593
642
  try {
594
- return lstatSync2(resolve4(destRoot, f)).isDirectory();
643
+ return lstatSync(resolve4(destRoot, f)).isDirectory();
595
644
  } catch {
596
645
  return false;
597
646
  }
@@ -619,15 +668,13 @@ ${t.toUpperCase()} ${cosmetic3.blue.encoder(destRoot)} (not found)`);
619
668
  const titleW = Math.min(50, Math.max(10, ...filtered.map((e) => e.title.length)) + 2);
620
669
  const divider = "\u2500".repeat(titleW + 44);
621
670
  console.log(`
622
- ${cosmetic3.yellow.encoder(t.toUpperCase())} ${cosmetic3.blue.encoder(destRoot)}`);
671
+ ${cosmetic5.yellow.encoder(t.toUpperCase())} ${cosmetic5.blue.encoder(destRoot)}`);
623
672
  console.log(divider);
624
673
  console.log(`${"Title".padEnd(titleW)} ${"Year".padEnd(6)} ${"Res".padEnd(6)} ${"Codec".padEnd(6)} ${"Size".padEnd(10)} Sub`);
625
674
  console.log(divider);
626
675
  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
- );
676
+ const sub = e.hasSub ? cosmetic5.green.encoder("\u2713") : cosmetic5.red.encoder("\u2717");
677
+ console.log(`${col(e.title, titleW)} ${col(e.year?.toString() ?? "\u2014", 6)} ${col(e.resolution ?? "\u2014", 6)} ${col(e.codec ?? "\u2014", 6)} ${col(e.size, 10)} ${sub}`);
631
678
  }
632
679
  console.log(divider);
633
680
  console.log(`${filtered.length} of ${entries.length} item${entries.length !== 1 ? "s" : ""}`);
@@ -636,29 +683,118 @@ ${cosmetic3.yellow.encoder(t.toUpperCase())} ${cosmetic3.blue.encoder(destRoot)
636
683
  };
637
684
  var list_default = list;
638
685
 
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;
686
+ // src/actions/probe.ts
687
+ import { spawnSync } from "child_process";
688
+ import cosmetic6 from "cosmetic";
689
+ import { existsSync as existsSync6, lstatSync as lstatSync2, readdirSync as readdirSync4 } from "fs";
690
+ import { resolve as resolve5 } from "path";
691
+ var DEST_TYPES3 = ["movie", "tv", "ps3"];
692
+ var CODEC_MAP2 = {
693
+ hevc: "x265",
694
+ h265: "x265",
695
+ h264: "x264",
696
+ avc: "x264",
697
+ av1: "AV1",
698
+ vp9: "VP9",
699
+ vp8: "VP8",
700
+ xvid: "XviD",
701
+ mpeg4: "XviD",
702
+ mpeg2video: "MPEG-2"
703
+ };
704
+ var deriveResolution = (width, height) => {
705
+ if (height >= 2160 || width >= 3840) return "2160p";
706
+ if (height >= 1080 || width >= 1920) return "1080p";
707
+ if (height >= 720 || width >= 1280) return "720p";
708
+ if (height >= 576 || width >= 768) return "576p";
709
+ return "480p";
710
+ };
711
+ var isFfprobeAvailable = () => {
712
+ const result = spawnSync("ffprobe", ["-version"], { encoding: "utf-8" });
713
+ return !result.error && result.status === 0;
714
+ };
715
+ var runFfprobe = (filePath) => {
716
+ const result = spawnSync("ffprobe", ["-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filePath], { encoding: "utf-8" });
717
+ if (result.error || result.status !== 0) return null;
718
+ try {
719
+ const data = JSON.parse(result.stdout);
720
+ const video = data.streams?.find((s) => s.codec_type === "video");
721
+ if (!video) return null;
722
+ const width = video.width ?? null;
723
+ const height = video.height ?? null;
724
+ return {
725
+ codec: CODEC_MAP2[video.codec_name?.toLowerCase()] ?? video.codec_name ?? null,
726
+ resolution: width && height ? deriveResolution(width, height) : null,
727
+ width: width ?? null,
728
+ height: height ?? null,
729
+ duration: data.format?.duration ? parseFloat(data.format.duration) : null
730
+ };
731
+ } catch {
732
+ return null;
653
733
  }
654
- return null;
655
734
  };
735
+ var walkVideoFiles = (dir, depth = 0, maxDepth = 3) => {
736
+ if (!existsSync6(dir) || depth > maxDepth) return [];
737
+ const results = [];
738
+ for (const entry of readdirSync4(dir)) {
739
+ const entryPath = resolve5(dir, entry);
740
+ try {
741
+ if (lstatSync2(entryPath).isDirectory()) {
742
+ results.push(...walkVideoFiles(entryPath, depth + 1, maxDepth));
743
+ } else {
744
+ const ext = entry.match(/([^.]+$)/)?.[0]?.toLowerCase();
745
+ if (ext && videoExtensions_default.includes(ext)) results.push(entryPath);
746
+ }
747
+ } catch {
748
+ }
749
+ }
750
+ return results;
751
+ };
752
+ var probe = async ({ type, force, verbose }) => {
753
+ spinner_default.start();
754
+ if (!isFfprobeAvailable()) {
755
+ spinner_default.fail("ffprobe not found \u2014 install ffmpeg to use this command");
756
+ spinner_default.stop();
757
+ return;
758
+ }
759
+ const config = getConfig();
760
+ const types = (type ? [type] : DEST_TYPES3).filter((t) => config.dest[t]);
761
+ if (types.length === 0) throw new Error("no destinations configured \u2014 run: reelsort config set dest movie <dir>");
762
+ let probed = 0, skipped = 0, failed = 0;
763
+ for (const t of types) {
764
+ const destRoot = config.dest[t];
765
+ if (!existsSync6(destRoot)) continue;
766
+ spinner_default.text = `scanning ${cosmetic6.blue.encoder(destRoot)}`;
767
+ const files = walkVideoFiles(destRoot);
768
+ for (const filePath of files) {
769
+ if (!force && getMediaInfo(filePath)) {
770
+ if (verbose) spinner_default.info(`already probed: ${filePath}`);
771
+ skipped++;
772
+ continue;
773
+ }
774
+ spinner_default.text = `probing ${cosmetic6.blue.encoder(filePath)}`;
775
+ const result = runFfprobe(filePath);
776
+ if (!result) {
777
+ if (verbose) spinner_default.warn(`ffprobe failed: ${filePath}`);
778
+ failed++;
779
+ continue;
780
+ }
781
+ upsertMediaInfo(filePath, result.codec, result.resolution, result.width, result.height, result.duration);
782
+ if (verbose) spinner_default.succeed(`${result.resolution ?? "?"} ${result.codec ?? "?"} ${filePath}`);
783
+ probed++;
784
+ }
785
+ }
786
+ spinner_default.succeed(`probed ${probed} files`);
787
+ if (skipped) spinner_default.info(`skipped ${skipped} already indexed`);
788
+ if (failed) spinner_default.warn(`failed ${failed} files`);
789
+ spinner_default.stop();
790
+ };
791
+ var probe_default = probe;
656
792
 
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";
793
+ // src/actions/rename.ts
794
+ import cosmetic7 from "cosmetic";
795
+ import { existsSync as existsSync7, lstatSync as lstatSync3, readdirSync as readdirSync5, renameSync } from "fs";
796
+ import { resolve as resolve6 } from "path";
797
+ import { rimraf } from "rimraf";
662
798
 
663
799
  // src/helpers/findSubtitle.ts
664
800
  var LANGUAGE_ALIASES = {
@@ -712,66 +848,197 @@ var titleCase_default = (s) => {
712
848
  return s;
713
849
  };
714
850
 
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();
851
+ // src/actions/rename.ts
852
+ var rename = async ({ dir: inputDir, type, verbose }) => {
853
+ const dir = resolve6(inputDir);
854
+ const sessionId = (/* @__PURE__ */ new Date()).toISOString();
855
+ const config = getConfig();
856
+ const language = config.language ?? "eng";
857
+ const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
858
+ spinner_default.text = `renaming in ${cosmetic7.blue.encoder(dir)}`;
859
+ spinner_default.start();
860
+ if (!existsSync7(dir)) throw new Error(`dir ${dir} does not exist`);
861
+ const list2 = readdirSync5(dir);
862
+ let renamed = 0, removed = 0, skipped = 0;
863
+ for (const [index, entry] of list2.entries()) {
864
+ spinner_default.text = `renaming in ${cosmetic7.blue.encoder(dir)} ${index + 1}/${list2.length}`;
865
+ if (!lstatSync3(resolve6(dir, entry)).isDirectory()) {
866
+ if (verbose) spinner_default.info(`skipped ${entry}`);
867
+ skipped++;
868
+ continue;
869
+ }
870
+ const isPs3Candidate = /(?<=\[).+?(?=\])/.test(entry);
871
+ const usePs3 = type === "ps3" || !type && isPs3Candidate;
872
+ if (usePs3) {
873
+ const nameMatch = entry.match(/(?<=\[).+?(?=\])/);
874
+ const id = entry.split("-")[0];
875
+ if (!nameMatch || !id) {
876
+ if (verbose) spinner_default.info(`skipped ${entry}`);
877
+ skipped++;
878
+ continue;
879
+ }
880
+ const ps3Old = resolve6(dir, entry);
881
+ const ps3New = resolve6(dir, `${nameMatch[0]} [${id}]`);
882
+ renameSync(ps3Old, ps3New);
883
+ recordRename(sessionId, ps3Old, ps3New);
884
+ spinner_default.succeed(`${nameMatch[0]} [${id}]`);
885
+ renamed++;
886
+ continue;
887
+ }
888
+ const yearMatch = entry.match(/\([^\d]*(\d+)[^\d]*\)/);
889
+ if (!yearMatch) {
890
+ if (verbose) spinner_default.info(`skipped ${entry}`);
891
+ skipped++;
892
+ continue;
893
+ }
894
+ const year = yearMatch[0];
895
+ if (year.length !== 6) {
896
+ if (verbose) spinner_default.info(`skipped ${entry}`);
897
+ skipped++;
898
+ continue;
899
+ }
900
+ const title = titleCase_default(entry.substring(0, entry.indexOf(year)).trim());
901
+ const sublist = readdirSync5(resolve6(dir, entry));
902
+ const video = sublist.find((f) => {
903
+ const ext2 = f.match(/([^.]+$)/)?.[0];
904
+ return videoExtensions_default.includes(ext2) && title.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
905
+ });
906
+ if (!video) {
907
+ if (verbose) spinner_default.info(`skipped ${entry}`);
908
+ skipped++;
909
+ continue;
910
+ }
911
+ const ext = video.match(/([^.]+$)/)?.[0];
912
+ if (!ext) {
913
+ if (verbose) spinner_default.info(`skipped ${entry}`);
914
+ skipped++;
915
+ continue;
916
+ }
917
+ const yearNum = parseInt(year.replace(/\D/g, ""));
918
+ const formatted = formatMovieName(movieFormat, title, yearNum);
919
+ if (entry === formatted && video === `${formatted}.${ext}`) {
920
+ if (verbose) spinner_default.info(`skipped ${entry}`);
921
+ skipped++;
922
+ continue;
923
+ }
924
+ const subtitle = findSubtitle(sublist, language);
925
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
926
+ const keep = new Set([video, subtitle].filter(Boolean));
927
+ const others = sublist.filter((f) => !keep.has(f));
928
+ for (const f of others) {
929
+ await rimraf(resolve6(dir, entry, f));
930
+ removed++;
931
+ }
932
+ const fileOld = resolve6(dir, entry, video);
933
+ const fileNew = resolve6(dir, entry, `${formatted}.${ext}`);
934
+ const folderOld = resolve6(dir, entry);
935
+ const folderNew = resolve6(dir, formatted);
936
+ renameSync(fileOld, fileNew);
937
+ if (subtitle && subtitleExt) {
938
+ renameSync(resolve6(dir, entry, subtitle), resolve6(dir, entry, `${formatted}.${subtitleExt}`));
939
+ }
940
+ renameSync(folderOld, folderNew);
941
+ recordRename(sessionId, fileOld, fileNew);
942
+ recordRename(sessionId, folderOld, folderNew);
943
+ spinner_default.succeed(formatted);
944
+ renamed++;
945
+ }
946
+ spinner_default.succeed(`renamed ${renamed} files`);
947
+ if (removed) spinner_default.info(`removed ${removed} files`);
948
+ spinner_default.info(`skipped ${skipped} files`);
949
+ spinner_default.succeed(`done in ${cosmetic7.cyan.encoder(dir)}`);
950
+ spinner_default.stop();
951
+ };
952
+ var rename_default = rename;
953
+
954
+ // src/actions/reset.ts
955
+ import cosmetic8 from "cosmetic";
956
+ import { existsSync as existsSync8, readdirSync as readdirSync6, renameSync as renameSync2 } from "fs";
957
+ import { basename as basename2, dirname, resolve as resolve7, sep } from "path";
958
+ var reset = async ({ dir: inputDir, double }) => {
959
+ let dir = inputDir;
960
+ spinner_default.text = `resetting episodes in ${cosmetic8.blue.encoder(dir)}`;
961
+ spinner_default.start();
962
+ dir = resolve7(dir);
963
+ if (!existsSync8(dir)) throw new Error(`dir ${dir} does not exist`);
964
+ const list2 = readdirSync6(dir).sort();
965
+ const folder = dir.replace(/\./g, " ").split(sep).pop();
966
+ let season;
967
+ 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;
968
+ if (sub)
969
+ while (sub.search(/[0-9]/) === 0) {
970
+ season = `${season || ""}${sub.substring(0, 1)}`;
971
+ sub = sub.substring(1, sub.length);
972
+ }
973
+ const seasonNum = parseInt(season);
974
+ if (!seasonNum) throw new Error(`unable to identify season number`);
975
+ const parentFolder = basename2(dirname(dir));
976
+ const showTitle = parentFolder.match(/^(.+?)\s*(?:\(\d{4}\))?$/)?.[1]?.trim() || void 0;
977
+ spinner_default.info(`identified as season ${seasonNum}${showTitle ? ` of ${showTitle}` : ""}`);
978
+ const sublist = list2.filter((f) => {
979
+ const ext = f.match(/([^.]+$)/)?.[0];
980
+ return videoExtensions_default.includes(ext) && f.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
981
+ });
982
+ const other = list2.filter((f) => {
983
+ const ext = f.match(/([^.]+$)/)?.[0];
984
+ return !videoExtensions_default.includes(ext) && f.split(" ").reduce((a, w) => f.toLowerCase().includes(w.toLowerCase()) ? a : false, true);
985
+ });
986
+ const episodeFormat = getConfig().format?.episode;
987
+ let renamed = 0, skipped = other.length;
988
+ for (const [index, i] of sublist.entries()) {
989
+ spinner_default.text = `resetting episodes in ${cosmetic8.blue.encoder(dir)} ${index}/${list2.length}`;
990
+ const ext = i.match(/([^.]+$)/)?.[0];
991
+ const episode = double ? index * 2 + 1 : index + 1;
992
+ const name = `${formatEpisode(seasonNum, episode, episodeFormat, double, showTitle)}.${ext}`;
993
+ if (i === name) {
994
+ skipped++;
995
+ continue;
996
+ }
997
+ renameSync2(resolve7(dir, i), resolve7(dir, name));
998
+ renamed++;
999
+ }
1000
+ spinner_default.succeed(`renamed ${renamed} files`);
1001
+ spinner_default.info(`skipped ${skipped} files`);
1002
+ spinner_default.succeed(`done in ${cosmetic8.cyan.encoder(dir)}`);
1003
+ spinner_default.stop();
1004
+ };
1005
+ var reset_default = reset;
1006
+
1007
+ // src/actions/scan.ts
1008
+ import cosmetic9 from "cosmetic";
1009
+ import { cpSync, existsSync as existsSync9, linkSync, lstatSync as lstatSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync7, renameSync as renameSync3, rmSync as rmSync2, statSync as statSync2 } from "fs";
1010
+ import { dirname as dirname2, resolve as resolve8 } from "path";
1011
+ import { Select } from "termpulse";
1012
+
1013
+ // src/helpers/detectEdition.ts
1014
+ var EDITIONS = [
1015
+ { pattern: /director.?s?.?cut/i, name: "Director's Cut" },
1016
+ { pattern: /final\.?cut/i, name: "Final Cut" },
1017
+ { pattern: /extended\.?(cut|edition|version)?/i, name: "Extended" },
1018
+ { pattern: /theatrical\.?(cut|version)?/i, name: "Theatrical" },
1019
+ { pattern: /unrated\.?(cut|version)?/i, name: "Unrated" },
1020
+ { pattern: /anniversary\.?edition/i, name: "Anniversary Edition" },
1021
+ { pattern: /collector.?s?.?(edition|cut)/i, name: "Collector's Edition" },
1022
+ { pattern: /special\.?edition/i, name: "Special Edition" }
1023
+ ];
1024
+ var detectEdition = (filename) => {
1025
+ for (const { pattern, name } of EDITIONS) {
1026
+ if (pattern.test(filename)) return name;
1027
+ }
1028
+ return null;
1029
+ };
1030
+
1031
+ // src/helpers/hyperlink.ts
1032
+ var hyperlink = (url, text) => `\x1B]8;;${url}\x1B\\${text}\x1B]8;;\x1B\\`;
1033
+
1034
+ // src/helpers/parseDownloadName.ts
1035
+ var QUALITY_TOKENS = /* @__PURE__ */ new Set(["480p", "576p", "720p", "1080p", "2160p", "4k", "8k", "bluray", "bdrip", "bdremux", "brrip", "webrip", "web-dl", "webdl", "web", "hdtv", "dvdrip", "dvdscr", "cam", "ts", "scr", "x264", "x265", "hevc", "avc", "h264", "h265", "xvid", "divx", "dts", "ac3", "aac", "mp3", "truehd", "atmos", "dd5", "hdr", "hdr10", "hlg", "dv", "dolby", "remux", "proper", "repack", "extended", "theatrical", "unrated", "multi", "dubbed", "subbed", "internal"]);
1036
+ var TV_PATTERN = /^(.*?)[.\s_-]*(?:S(\d{2,3})E(\d{2,3})|(\d{1,2})x(\d{2,3})|Season[\s.](\d+))/i;
1037
+ var parseDownloadName = (name) => {
1038
+ const base = name.replace(/\.[a-z0-9]{2,4}$/i, "");
1039
+ const tvMatch = TV_PATTERN.exec(base);
1040
+ if (tvMatch) {
1041
+ const raw = tvMatch[1].replace(/[._]/g, " ").replace(/\[.*?\]/g, "").trim();
775
1042
  const title = titleCase_default(raw);
776
1043
  if (!title) return null;
777
1044
  const season = parseInt(tvMatch[2] ?? tvMatch[4] ?? tvMatch[6]);
@@ -860,33 +1127,33 @@ var searchTv = async (title, apiKey) => {
860
1127
  }
861
1128
  };
862
1129
 
863
- // src/actions/watch.ts
1130
+ // src/actions/scan.ts
864
1131
  var sameDev = (a, b) => {
865
1132
  try {
866
1133
  let bExisting = b;
867
- while (!existsSync5(bExisting)) bExisting = dirname(bExisting);
1134
+ while (!existsSync9(bExisting)) bExisting = dirname2(bExisting);
868
1135
  return statSync2(a).dev === statSync2(bExisting).dev;
869
1136
  } catch {
870
1137
  return false;
871
1138
  }
872
1139
  };
873
- var moveItem = (src, dest) => {
1140
+ var moveFolder = (src, dest) => {
874
1141
  if (sameDev(src, dest)) {
875
- renameSync(src, dest);
1142
+ renameSync3(src, dest);
876
1143
  } else {
877
1144
  cpSync(src, dest, { recursive: true });
878
- rmSync(src, { recursive: true, force: true });
1145
+ rmSync2(src, { recursive: true, force: true });
879
1146
  }
880
1147
  };
881
- var findVideo = (dir) => readdirSync4(dir).find((f) => {
1148
+ var findVideo = (dir) => readdirSync7(dir).find((f) => {
882
1149
  const ext = f.match(/([^.]+$)/)?.[0];
883
1150
  return ext && videoExtensions_default.includes(ext);
884
1151
  }) ?? null;
885
1152
  var findSeasonFolder = (showPath, season) => {
886
- if (!existsSync5(showPath)) return null;
887
- const folders = readdirSync4(showPath).filter((f) => {
1153
+ if (!existsSync9(showPath)) return null;
1154
+ const folders = readdirSync7(showPath).filter((f) => {
888
1155
  try {
889
- return lstatSync3(resolve5(showPath, f)).isDirectory();
1156
+ return lstatSync4(resolve8(showPath, f)).isDirectory();
890
1157
  } catch {
891
1158
  return false;
892
1159
  }
@@ -896,332 +1163,30 @@ var findSeasonFolder = (showPath, season) => {
896
1163
  return match && parseInt(match[1]) === season;
897
1164
  }) ?? null;
898
1165
  };
899
- var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1166
+ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
900
1167
  const config = getConfig();
901
1168
  const sessionId = (/* @__PURE__ */ new Date()).toISOString();
902
- const entry = basename(entryPath);
1169
+ const language = config.language ?? "eng";
903
1170
  const movieFormat = config.format?.movie ?? DEFAULT_MOVIE_FORMAT;
904
1171
  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;
1172
+ spinner_default.start();
1173
+ if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1174
+ let imported = 0, skipped = 0;
1175
+ for (const source of config.sources) {
1176
+ if (!existsSync9(source)) {
1177
+ spinner_default.warn(`source not found: ${cosmetic9.blue.encoder(source)}`);
1178
+ continue;
977
1179
  }
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;
1180
+ spinner_default.text = `scanning ${cosmetic9.blue.encoder(source)}`;
1181
+ for (const entry of readdirSync7(source)) {
1182
+ const entryPath = resolve8(source, entry);
1183
+ const isDir = lstatSync4(entryPath).isDirectory();
1184
+ const ext = entry.match(/([^.]+$)/)?.[0];
1185
+ const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1186
+ if (!isDir && !isVideo) {
1187
+ if (verbose) spinner_default.info(`skipped ${entry}`);
1188
+ skipped++;
1189
+ continue;
1225
1190
  }
1226
1191
  let detectedType;
1227
1192
  if (type) {
@@ -1247,8 +1212,8 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1247
1212
  continue;
1248
1213
  }
1249
1214
  const destName = `${nameMatch[0]} [${id}]`;
1250
- const destPath = resolve6(destRoot, destName);
1251
- if (existsSync7(destPath)) {
1215
+ const destPath = resolve8(destRoot, destName);
1216
+ if (existsSync9(destPath)) {
1252
1217
  spinner_default.warn(`already exists: ${destName}`);
1253
1218
  skipped++;
1254
1219
  continue;
@@ -1316,16 +1281,16 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1316
1281
  showFolderName = showPath.split("/").pop() ?? registeredShow.path;
1317
1282
  } else if (auto) {
1318
1283
  showFolderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear);
1319
- showPath = resolve6(destRoot, showFolderName);
1284
+ showPath = resolve8(destRoot, showFolderName);
1320
1285
  if (!dryRun) upsertShow(showPath, tmdbId ?? null, resolvedTitle);
1321
1286
  } else {
1322
1287
  if (verbose) spinner_default.info(`not registered, skipped: ${resolvedTitle} \u2014 run: reelsort add "${resolvedTitle}"`);
1323
1288
  skipped++;
1324
1289
  continue;
1325
1290
  }
1326
- const seasonFolderName = findSeasonFolder2(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1327
- const seasonPath = resolve6(showPath, seasonFolderName);
1328
- const videoFile2 = isDir ? findVideo2(entryPath) : entry;
1291
+ const seasonFolderName = findSeasonFolder(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1292
+ const seasonPath = resolve8(showPath, seasonFolderName);
1293
+ const videoFile2 = isDir ? findVideo(entryPath) : entry;
1329
1294
  if (!videoFile2) {
1330
1295
  if (verbose) spinner_default.info(`no video found in: ${entry}`);
1331
1296
  skipped++;
@@ -1335,41 +1300,41 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1335
1300
  const tmdbEpisodeName = tmdbId && config.tmdbApiKey ? await getEpisodeName(tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1336
1301
  const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, resolvedTitle, tmdbEpisodeName ?? void 0);
1337
1302
  const destVideoName2 = `${episodeName}.${videoExt2}`;
1338
- const destVideoPath = resolve6(seasonPath, destVideoName2);
1339
- const videoSourcePath2 = isDir ? resolve6(entryPath, videoFile2) : entryPath;
1340
- if (existsSync7(destVideoPath)) {
1303
+ const destVideoPath = resolve8(seasonPath, destVideoName2);
1304
+ const videoSourcePath2 = isDir ? resolve8(entryPath, videoFile2) : entryPath;
1305
+ if (existsSync9(destVideoPath)) {
1341
1306
  spinner_default.warn(`already exists: ${episodeName}`);
1342
1307
  skipped++;
1343
1308
  continue;
1344
1309
  }
1345
- const dirFiles2 = isDir ? readdirSync5(entryPath) : [];
1310
+ const dirFiles2 = isDir ? readdirSync7(entryPath) : [];
1346
1311
  const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
1347
1312
  const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
1348
- const subtitleSourcePath2 = subtitle2 ? resolve6(entryPath, subtitle2) : null;
1313
+ const subtitleSourcePath2 = subtitle2 ? resolve8(entryPath, subtitle2) : null;
1349
1314
  const destSubtitleName2 = subtitle2 && subtitleExt2 ? `${episodeName}.${subtitleExt2}` : null;
1350
1315
  if (!dryRun) {
1351
- mkdirSync4(seasonPath, { recursive: true });
1316
+ mkdirSync3(seasonPath, { recursive: true });
1352
1317
  let mode = "move";
1353
1318
  if (useHardlink) {
1354
1319
  try {
1355
- if (!sameDev2(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1356
- linkSync2(videoSourcePath2, destVideoPath);
1320
+ if (!sameDev(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1321
+ linkSync(videoSourcePath2, destVideoPath);
1357
1322
  mode = "hardlink";
1358
1323
  } catch {
1359
1324
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1360
- cpSync2(videoSourcePath2, destVideoPath);
1325
+ cpSync(videoSourcePath2, destVideoPath);
1361
1326
  mode = "copy";
1362
1327
  }
1363
- if (subtitleSourcePath2 && destSubtitleName2) cpSync2(subtitleSourcePath2, resolve6(seasonPath, destSubtitleName2));
1328
+ if (subtitleSourcePath2 && destSubtitleName2) cpSync(subtitleSourcePath2, resolve8(seasonPath, destSubtitleName2));
1364
1329
  } else {
1365
- if (sameDev2(videoSourcePath2, seasonPath)) {
1366
- renameSync2(videoSourcePath2, destVideoPath);
1330
+ if (sameDev(videoSourcePath2, seasonPath)) {
1331
+ renameSync3(videoSourcePath2, destVideoPath);
1367
1332
  } else {
1368
- cpSync2(videoSourcePath2, destVideoPath);
1369
- rmSync3(videoSourcePath2);
1333
+ cpSync(videoSourcePath2, destVideoPath);
1334
+ rmSync2(videoSourcePath2);
1370
1335
  }
1371
- if (subtitleSourcePath2 && destSubtitleName2) renameSync2(subtitleSourcePath2, resolve6(seasonPath, destSubtitleName2));
1372
- if (isDir) rmSync3(entryPath, { recursive: true, force: true });
1336
+ if (subtitleSourcePath2 && destSubtitleName2) renameSync3(subtitleSourcePath2, resolve8(seasonPath, destSubtitleName2));
1337
+ if (isDir) rmSync2(entryPath, { recursive: true, force: true });
1373
1338
  }
1374
1339
  recordImport(sessionId, entryPath, seasonPath, mode, tmdbId);
1375
1340
  }
@@ -1379,13 +1344,13 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1379
1344
  }
1380
1345
  const edition = detectEdition(entry);
1381
1346
  const folderName = formatMovieName(movieFormat, resolvedTitle, resolvedYear, edition);
1382
- const destFolder = resolve6(destRoot, folderName);
1383
- if (existsSync7(destFolder)) {
1347
+ const destFolder = resolve8(destRoot, folderName);
1348
+ if (existsSync9(destFolder)) {
1384
1349
  spinner_default.warn(`already exists: ${folderName}`);
1385
1350
  skipped++;
1386
1351
  continue;
1387
1352
  }
1388
- const videoFile = isDir ? findVideo2(entryPath) : entry;
1353
+ const videoFile = isDir ? findVideo(entryPath) : entry;
1389
1354
  if (!videoFile) {
1390
1355
  if (verbose) spinner_default.info(`no video found in: ${entry}`);
1391
1356
  skipped++;
@@ -1393,43 +1358,43 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1393
1358
  }
1394
1359
  const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1395
1360
  const destVideoName = `${folderName}.${videoExt}`;
1396
- const videoSourcePath = isDir ? resolve6(entryPath, videoFile) : entryPath;
1397
- const dirFiles = isDir ? readdirSync5(entryPath) : [];
1361
+ const videoSourcePath = isDir ? resolve8(entryPath, videoFile) : entryPath;
1362
+ const dirFiles = isDir ? readdirSync7(entryPath) : [];
1398
1363
  const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1399
1364
  const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1400
- const subtitleSourcePath = subtitle ? resolve6(entryPath, subtitle) : null;
1365
+ const subtitleSourcePath = subtitle ? resolve8(entryPath, subtitle) : null;
1401
1366
  const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1402
1367
  if (!dryRun) {
1403
1368
  if (useHardlink) {
1404
- mkdirSync4(destFolder, { recursive: true });
1405
- const destVideoPath = resolve6(destFolder, destVideoName);
1369
+ mkdirSync3(destFolder, { recursive: true });
1370
+ const destVideoPath = resolve8(destFolder, destVideoName);
1406
1371
  let mode;
1407
1372
  try {
1408
- if (!sameDev2(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1409
- linkSync2(videoSourcePath, destVideoPath);
1373
+ if (!sameDev(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1374
+ linkSync(videoSourcePath, destVideoPath);
1410
1375
  mode = "hardlink";
1411
1376
  } catch {
1412
1377
  spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1413
- cpSync2(videoSourcePath, destVideoPath);
1378
+ cpSync(videoSourcePath, destVideoPath);
1414
1379
  mode = "copy";
1415
1380
  }
1416
- if (subtitleSourcePath && destSubtitleName) cpSync2(subtitleSourcePath, resolve6(destFolder, destSubtitleName));
1381
+ if (subtitleSourcePath && destSubtitleName) cpSync(subtitleSourcePath, resolve8(destFolder, destSubtitleName));
1417
1382
  recordImport(sessionId, entryPath, destFolder, mode, tmdbId);
1418
1383
  } else {
1419
1384
  if (isDir) {
1420
1385
  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));
1386
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) rmSync2(resolve8(entryPath, f), { recursive: true, force: true });
1387
+ renameSync3(videoSourcePath, resolve8(entryPath, destVideoName));
1388
+ if (subtitleSourcePath && destSubtitleName) renameSync3(subtitleSourcePath, resolve8(entryPath, destSubtitleName));
1424
1389
  moveFolder(entryPath, destFolder);
1425
1390
  } else {
1426
- mkdirSync4(destFolder, { recursive: true });
1427
- const destVideoPath = resolve6(destFolder, destVideoName);
1428
- if (sameDev2(videoSourcePath, destRoot)) {
1429
- renameSync2(videoSourcePath, destVideoPath);
1391
+ mkdirSync3(destFolder, { recursive: true });
1392
+ const destVideoPath = resolve8(destFolder, destVideoName);
1393
+ if (sameDev(videoSourcePath, destRoot)) {
1394
+ renameSync3(videoSourcePath, destVideoPath);
1430
1395
  } else {
1431
- cpSync2(videoSourcePath, destVideoPath);
1432
- rmSync3(videoSourcePath);
1396
+ cpSync(videoSourcePath, destVideoPath);
1397
+ rmSync2(videoSourcePath);
1433
1398
  }
1434
1399
  }
1435
1400
  recordImport(sessionId, entryPath, destFolder, "move", tmdbId);
@@ -1445,55 +1410,10 @@ var scan = async ({ type, hardlink: useHardlink, dryRun, verbose, auto }) => {
1445
1410
  };
1446
1411
  var scan_default = scan;
1447
1412
 
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
1413
  // src/actions/undo.ts
1494
- import cosmetic8 from "cosmetic";
1495
- import { renameSync as renameSync3 } from "fs";
1496
- var undo = async (_) => {
1414
+ import cosmetic10 from "cosmetic";
1415
+ import { renameSync as renameSync4 } from "fs";
1416
+ var undo = async () => {
1497
1417
  spinner_default.start();
1498
1418
  const records = getLastSession();
1499
1419
  if (records.length === 0) {
@@ -1503,8 +1423,8 @@ var undo = async (_) => {
1503
1423
  }
1504
1424
  let undone = 0;
1505
1425
  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)}`);
1426
+ renameSync4(record.newPath, record.oldPath);
1427
+ spinner_default.succeed(`${cosmetic10.cyan.encoder(record.newPath)} \u2192 ${cosmetic10.blue.encoder(record.oldPath)}`);
1508
1428
  undone++;
1509
1429
  }
1510
1430
  deleteSession(records[0].sessionId);
@@ -1513,217 +1433,247 @@ var undo = async (_) => {
1513
1433
  };
1514
1434
  var undo_default = undo;
1515
1435
 
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
- });
1436
+ // src/actions/watch.ts
1437
+ import chokidar from "chokidar";
1438
+ import cosmetic11 from "cosmetic";
1439
+ import { cpSync as cpSync2, existsSync as existsSync10, linkSync as linkSync2, lstatSync as lstatSync5, mkdirSync as mkdirSync4, readdirSync as readdirSync8, renameSync as renameSync5, rmSync as rmSync3, statSync as statSync3 } from "fs";
1440
+ import { basename as basename3, dirname as dirname3, resolve as resolve9 } from "path";
1441
+ var sameDev2 = (a, b) => {
1442
+ try {
1443
+ let bExisting = b;
1444
+ while (!existsSync10(bExisting)) bExisting = dirname3(bExisting);
1445
+ return statSync3(a).dev === statSync3(bExisting).dev;
1446
+ } catch {
1447
+ return false;
1540
1448
  }
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
- });
1449
+ };
1450
+ var moveItem = (src, dest) => {
1451
+ if (sameDev2(src, dest)) {
1452
+ renameSync5(src, dest);
1453
+ } else {
1454
+ cpSync2(src, dest, { recursive: true });
1455
+ rmSync3(src, { recursive: true, force: true });
1550
1456
  }
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);
1457
+ };
1458
+ var findVideo2 = (dir) => readdirSync8(dir).find((f) => {
1459
+ const ext = f.match(/([^.]+$)/)?.[0];
1460
+ return ext && videoExtensions_default.includes(ext);
1461
+ }) ?? null;
1462
+ var findSeasonFolder2 = (showPath, season) => {
1463
+ if (!existsSync10(showPath)) return null;
1464
+ const folders = readdirSync8(showPath).filter((f) => {
1465
+ try {
1466
+ return lstatSync5(resolve9(showPath, f)).isDirectory();
1467
+ } catch {
1468
+ return false;
1557
1469
  }
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}`);
1470
+ });
1471
+ return folders.find((f) => {
1472
+ const match = f.match(/(?:season|s)\s*0*(\d+)/i);
1473
+ return match && parseInt(match[1]) === season;
1474
+ }) ?? null;
1565
1475
  };
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();
1476
+ var processItem = async (entryPath, useHardlink, verbose, language, auto) => {
1576
1477
  const config = getConfig();
1577
- const language = config.language ?? "eng";
1478
+ const sessionId = (/* @__PURE__ */ new Date()).toISOString();
1479
+ const entry = basename3(entryPath);
1578
1480
  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;
1481
+ const seasonFormat = config.format?.season ?? DEFAULT_SEASON_FORMAT;
1482
+ const isDir = lstatSync5(entryPath).isDirectory();
1483
+ const ext = entry.match(/([^.]+$)/)?.[0];
1484
+ const isVideo = !isDir && ext && videoExtensions_default.includes(ext);
1485
+ if (!isDir && !isVideo) return;
1486
+ let detectedType;
1487
+ if (isDir && /(?<=\[).+?(?=\])/.test(entry)) {
1488
+ detectedType = "ps3";
1489
+ } else if (/S\d{2,3}E\d{2,3}/i.test(entry) || /\d+x\d{2,3}/i.test(entry)) {
1490
+ detectedType = "tv";
1491
+ } else {
1492
+ detectedType = "movie";
1493
+ }
1494
+ const destRoot = config.dest[detectedType];
1495
+ if (!destRoot) {
1496
+ if (verbose) spinner_default.info(`no ${detectedType} destination configured, skipped: ${entry}`);
1497
+ return;
1498
+ }
1499
+ if (detectedType === "ps3") {
1500
+ const nameMatch = entry.match(/(?<=\[).+?(?=\])/);
1501
+ const id = entry.split("-")[0];
1502
+ if (!nameMatch || !id) return;
1503
+ const destName = `${nameMatch[0]} [${id}]`;
1504
+ const destPath = resolve9(destRoot, destName);
1505
+ if (existsSync10(destPath)) {
1506
+ spinner_default.warn(`already exists: ${destName}`);
1507
+ return;
1608
1508
  }
1609
- const yearMatch = entry.match(/\([^\d]*(\d+)[^\d]*\)/);
1610
- if (!yearMatch) {
1611
- if (verbose) spinner_default.info(`skipped ${entry}`);
1612
- skipped++;
1613
- continue;
1509
+ moveItem(entryPath, destPath);
1510
+ recordImport(sessionId, entryPath, destPath, "move");
1511
+ spinner_default.succeed(`imported ${cosmetic11.cyan.encoder(destName)}`);
1512
+ return;
1513
+ }
1514
+ const parsed = parseDownloadName(entry);
1515
+ if (!parsed) {
1516
+ if (verbose) spinner_default.info(`could not parse: ${entry}`);
1517
+ return;
1518
+ }
1519
+ if (detectedType === "tv") {
1520
+ if (parsed.season === void 0) {
1521
+ if (verbose) spinner_default.info(`could not detect season from: ${entry}`);
1522
+ return;
1614
1523
  }
1615
- const year = yearMatch[0];
1616
- if (year.length !== 6) {
1617
- if (verbose) spinner_default.info(`skipped ${entry}`);
1618
- skipped++;
1619
- continue;
1524
+ const registeredShow = getShowByTitle(parsed.title);
1525
+ let showPath;
1526
+ let showFolderName;
1527
+ if (registeredShow) {
1528
+ showPath = registeredShow.path;
1529
+ showFolderName = showPath.split("/").pop() ?? registeredShow.path;
1530
+ } else if (auto) {
1531
+ showFolderName = formatMovieName(movieFormat, parsed.title, parsed.year);
1532
+ showPath = resolve9(destRoot, showFolderName);
1533
+ upsertShow(showPath, null, parsed.title);
1534
+ } else {
1535
+ if (verbose) spinner_default.info(`not registered, skipped: ${parsed.title} \u2014 run: reelsort add "${parsed.title}"`);
1536
+ return;
1620
1537
  }
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;
1538
+ const seasonFolderName = findSeasonFolder2(showPath, parsed.season) ?? formatSeasonFolder(seasonFormat, parsed.season);
1539
+ const seasonPath = resolve9(showPath, seasonFolderName);
1540
+ const videoFile2 = isDir ? findVideo2(entryPath) : entry;
1541
+ if (!videoFile2) {
1542
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1543
+ return;
1631
1544
  }
1632
- const ext = video.match(/([^.]+$)/)?.[0];
1633
- if (!ext) {
1634
- if (verbose) spinner_default.info(`skipped ${entry}`);
1635
- skipped++;
1636
- continue;
1545
+ const videoExt2 = videoFile2.match(/([^.]+$)/)?.[0];
1546
+ const tmdbEpisodeName = registeredShow?.tmdbId && config.tmdbApiKey ? await getEpisodeName(registeredShow.tmdbId, parsed.season, parsed.episode ?? 1, config.tmdbApiKey) : null;
1547
+ const episodeName = formatEpisode(parsed.season, parsed.episode ?? 1, config.format?.episode, false, parsed.title, tmdbEpisodeName ?? void 0);
1548
+ const destVideoName2 = `${episodeName}.${videoExt2}`;
1549
+ const destVideoPath = resolve9(seasonPath, destVideoName2);
1550
+ const videoSourcePath2 = isDir ? resolve9(entryPath, videoFile2) : entryPath;
1551
+ if (existsSync10(destVideoPath)) {
1552
+ spinner_default.warn(`already exists: ${episodeName}`);
1553
+ return;
1637
1554
  }
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;
1555
+ const dirFiles2 = isDir ? readdirSync8(entryPath) : [];
1556
+ const subtitle2 = isDir ? findSubtitle(dirFiles2, language) : null;
1557
+ const subtitleExt2 = subtitle2?.match(/([^.]+$)/)?.[0];
1558
+ const subtitleSourcePath2 = subtitle2 ? resolve9(entryPath, subtitle2) : null;
1559
+ const destSubtitleName2 = subtitle2 && subtitleExt2 ? `${episodeName}.${subtitleExt2}` : null;
1560
+ mkdirSync4(seasonPath, { recursive: true });
1561
+ let mode = "move";
1562
+ if (useHardlink) {
1563
+ try {
1564
+ if (!sameDev2(videoSourcePath2, seasonPath)) throw new Error("cross-filesystem");
1565
+ linkSync2(videoSourcePath2, destVideoPath);
1566
+ mode = "hardlink";
1567
+ } catch {
1568
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${episodeName}`);
1569
+ cpSync2(videoSourcePath2, destVideoPath);
1570
+ mode = "copy";
1571
+ }
1572
+ if (subtitleSourcePath2 && destSubtitleName2) cpSync2(subtitleSourcePath2, resolve9(seasonPath, destSubtitleName2));
1573
+ } else {
1574
+ if (sameDev2(videoSourcePath2, seasonPath)) {
1575
+ renameSync5(videoSourcePath2, destVideoPath);
1576
+ } else {
1577
+ cpSync2(videoSourcePath2, destVideoPath);
1578
+ rmSync3(videoSourcePath2);
1579
+ }
1580
+ if (subtitleSourcePath2 && destSubtitleName2) renameSync5(subtitleSourcePath2, resolve9(seasonPath, destSubtitleName2));
1581
+ if (isDir) rmSync3(entryPath, { recursive: true, force: true });
1644
1582
  }
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++;
1583
+ recordImport(sessionId, entryPath, seasonPath, mode);
1584
+ spinner_default.succeed(`imported ${cosmetic11.cyan.encoder(`${showFolderName} / ${seasonFolderName} / ${episodeName}`)}`);
1585
+ return;
1586
+ }
1587
+ const edition = detectEdition(entry);
1588
+ const folderName = formatMovieName(movieFormat, parsed.title, parsed.year, edition);
1589
+ const destFolder = resolve9(destRoot, folderName);
1590
+ if (existsSync10(destFolder)) {
1591
+ spinner_default.warn(`already exists: ${folderName}`);
1592
+ return;
1593
+ }
1594
+ const videoFile = isDir ? findVideo2(entryPath) : entry;
1595
+ if (!videoFile) {
1596
+ if (verbose) spinner_default.info(`no video found in: ${entry}`);
1597
+ return;
1598
+ }
1599
+ const videoExt = videoFile.match(/([^.]+$)/)?.[0];
1600
+ const destVideoName = `${folderName}.${videoExt}`;
1601
+ const videoSourcePath = isDir ? resolve9(entryPath, videoFile) : entryPath;
1602
+ const dirFiles = isDir ? readdirSync8(entryPath) : [];
1603
+ const subtitle = isDir ? findSubtitle(dirFiles, language) : null;
1604
+ const subtitleExt = subtitle?.match(/([^.]+$)/)?.[0];
1605
+ const subtitleSourcePath = subtitle ? resolve9(entryPath, subtitle) : null;
1606
+ const destSubtitleName = subtitle && subtitleExt ? `${folderName}.${subtitleExt}` : null;
1607
+ if (useHardlink) {
1608
+ mkdirSync4(destFolder, { recursive: true });
1609
+ const destVideoPath = resolve9(destFolder, destVideoName);
1610
+ let mode;
1611
+ try {
1612
+ if (!sameDev2(videoSourcePath, destRoot)) throw new Error("cross-filesystem");
1613
+ linkSync2(videoSourcePath, destVideoPath);
1614
+ mode = "hardlink";
1615
+ } catch {
1616
+ spinner_default.warn(`hardlink unavailable \u2014 copying instead: ${folderName}`);
1617
+ cpSync2(videoSourcePath, destVideoPath);
1618
+ mode = "copy";
1652
1619
  }
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}`));
1620
+ if (subtitleSourcePath && destSubtitleName) cpSync2(subtitleSourcePath, resolve9(destFolder, destSubtitleName));
1621
+ recordImport(sessionId, entryPath, destFolder, mode);
1622
+ } else {
1623
+ if (isDir) {
1624
+ const keep = new Set([videoFile, subtitle].filter(Boolean));
1625
+ for (const f of dirFiles.filter((f2) => !keep.has(f2))) rmSync3(resolve9(entryPath, f), { recursive: true, force: true });
1626
+ renameSync5(videoSourcePath, resolve9(entryPath, destVideoName));
1627
+ if (subtitleSourcePath && destSubtitleName) renameSync5(subtitleSourcePath, resolve9(entryPath, destSubtitleName));
1628
+ moveItem(entryPath, destFolder);
1629
+ } else {
1630
+ mkdirSync4(destFolder, { recursive: true });
1631
+ const destVideoPath = resolve9(destFolder, destVideoName);
1632
+ if (sameDev2(videoSourcePath, destRoot)) {
1633
+ renameSync5(videoSourcePath, destVideoPath);
1634
+ } else {
1635
+ cpSync2(videoSourcePath, destVideoPath);
1636
+ rmSync3(videoSourcePath);
1637
+ }
1660
1638
  }
1661
- renameSync4(folderOld, folderNew);
1662
- recordRename(sessionId, fileOld, fileNew);
1663
- recordRename(sessionId, folderOld, folderNew);
1664
- spinner_default.succeed(formatted);
1665
- renamed++;
1639
+ recordImport(sessionId, entryPath, destFolder, "move");
1666
1640
  }
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();
1641
+ spinner_default.succeed(`imported ${cosmetic11.cyan.encoder(folderName)}`);
1672
1642
  };
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);
1643
+ var watch = async ({ hardlink = false, verbose = false, auto = false }) => {
1644
+ const config = getConfig();
1645
+ if (config.sources.length === 0) throw new Error("no sources configured \u2014 run: reelsort config add source <dir>");
1646
+ const language = config.language ?? "eng";
1647
+ const pending = /* @__PURE__ */ new Map();
1648
+ const handle = (path) => {
1649
+ const existing = pending.get(path);
1650
+ if (existing) clearTimeout(existing);
1651
+ pending.set(
1652
+ path,
1653
+ setTimeout(async () => {
1654
+ pending.delete(path);
1655
+ try {
1656
+ await processItem(path, hardlink, verbose, language, auto);
1657
+ } catch (err) {
1658
+ spinner_default.fail(`error processing ${path}: ${err.message}`);
1659
+ }
1660
+ }, 5e3)
1661
+ );
1662
+ };
1663
+ const watcher = chokidar.watch(config.sources, {
1664
+ depth: 0,
1665
+ ignoreInitial: true,
1666
+ awaitWriteFinish: { stabilityThreshold: 5e3, pollInterval: 500 }
1706
1667
  });
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)}`);
1668
+ watcher.on("addDir", handle);
1669
+ watcher.on("add", handle);
1670
+ spinner_default.start();
1671
+ spinner_default.succeed(`watching ${config.sources.length} source${config.sources.length !== 1 ? "s" : ""}`);
1672
+ for (const s of config.sources) spinner_default.info(` ${cosmetic11.blue.encoder(s)}`);
1724
1673
  spinner_default.stop();
1674
+ process.stdin.resume();
1725
1675
  };
1726
- var reset_default = reset;
1676
+ var watch_default = watch;
1727
1677
  export {
1728
1678
  DEFAULT_EPISODE_FORMAT,
1729
1679
  DEFAULT_MOVIE_FORMAT,