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