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