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