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