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