jukebox-media-server 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/client/assets/Watch-DNFM488K.js +203 -0
- package/dist/client/assets/index-BYBYJ0kS.css +1 -0
- package/dist/client/assets/index-COFfIwFC.js +58 -0
- package/dist/client/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/client/index.html +24 -4
- package/dist/client/{images/site.webmanifest → manifest.webmanifest} +9 -6
- package/dist/client/offline.html +107 -0
- package/dist/client/sw.js +1 -0
- package/dist/client/workbox-d3da0cfa.js +1 -0
- package/dist/server/index.js +2997 -239
- package/drizzle/0001_steady_puma.sql +1 -1
- package/drizzle/0003_public_speedball.sql +42 -0
- package/drizzle/0004_motionless_bug.sql +13 -0
- package/drizzle/0005_settings_table.sql +5 -0
- package/drizzle/0006_watch_progress_cascade.sql +20 -0
- package/drizzle/0007_scan_jobs.sql +10 -0
- package/drizzle/0008_search_fts.sql +154 -0
- package/drizzle/0009_subtitles.sql +10 -0
- package/drizzle/meta/0003_snapshot.json +804 -0
- package/drizzle/meta/0004_snapshot.json +880 -0
- package/drizzle/meta/0005_snapshot.json +911 -0
- package/drizzle/meta/0006_snapshot.json +911 -0
- package/drizzle/meta/0007_snapshot.json +980 -0
- package/drizzle/meta/0008_snapshot.json +980 -0
- package/drizzle/meta/0009_snapshot.json +1059 -0
- package/drizzle/meta/_journal.json +49 -0
- package/package.json +7 -2
- package/dist/client/assets/Watch-CXouQzhW.js +0 -203
- package/dist/client/assets/index-Bxm6Lfvp.css +0 -1
- package/dist/client/assets/index-Db82pRvr.js +0 -52
package/dist/server/index.js
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
|
-
import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from "fs";
|
|
2
|
-
import path, { basename, extname, join } from "path";
|
|
1
|
+
import { constants, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "fs";
|
|
2
|
+
import path, { basename, dirname, extname, join } from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
4
|
import { createServer } from "http";
|
|
5
|
-
import { Http2ServerRequest, constants } from "http2";
|
|
5
|
+
import { Http2ServerRequest, constants as constants$1 } from "http2";
|
|
6
6
|
import { Readable } from "stream";
|
|
7
|
-
import crypto$1 from "crypto";
|
|
8
|
-
import { createServer as createServer$1 } from "vite";
|
|
7
|
+
import crypto$1, { randomBytes, scrypt, timingSafeEqual } from "crypto";
|
|
9
8
|
import { versions } from "process";
|
|
10
9
|
import Database from "better-sqlite3";
|
|
11
10
|
import crypto$2 from "node:crypto";
|
|
12
11
|
import fs from "node:fs";
|
|
13
|
-
import { readdir, stat, writeFile } from "fs/promises";
|
|
14
|
-
import { homedir } from "os";
|
|
12
|
+
import { access, readFile, readdir, stat, writeFile } from "fs/promises";
|
|
13
|
+
import os, { homedir } from "os";
|
|
14
|
+
import { promisify } from "util";
|
|
15
|
+
import { EventEmitter } from "node:events";
|
|
16
|
+
import process$1 from "node:process";
|
|
17
|
+
import os$1 from "node:os";
|
|
18
|
+
import tty from "node:tty";
|
|
19
|
+
import { spawn } from "child_process";
|
|
15
20
|
//#region \0rolldown/runtime.js
|
|
21
|
+
var __create = Object.create;
|
|
16
22
|
var __defProp = Object.defineProperty;
|
|
23
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
24
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
25
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
26
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
27
|
+
var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
17
28
|
var __exportAll = (all, no_symbols) => {
|
|
18
29
|
let target = {};
|
|
19
30
|
for (var name in all) __defProp(target, name, {
|
|
@@ -23,6 +34,20 @@ var __exportAll = (all, no_symbols) => {
|
|
|
23
34
|
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
24
35
|
return target;
|
|
25
36
|
};
|
|
37
|
+
var __copyProps = (to, from, except, desc) => {
|
|
38
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
39
|
+
key = keys[i];
|
|
40
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
41
|
+
get: ((k) => from[k]).bind(null, key),
|
|
42
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return to;
|
|
46
|
+
};
|
|
47
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
48
|
+
value: mod,
|
|
49
|
+
enumerable: true
|
|
50
|
+
}) : target, mod));
|
|
26
51
|
//#endregion
|
|
27
52
|
//#region node_modules/@hono/node-server/dist/index.mjs
|
|
28
53
|
var RequestError = class extends Error {
|
|
@@ -320,7 +345,7 @@ var drainIncoming = (incoming) => {
|
|
|
320
345
|
incomingWithDrainState[incomingDraining] = true;
|
|
321
346
|
if (incoming instanceof Http2ServerRequest) {
|
|
322
347
|
try {
|
|
323
|
-
incoming.stream?.close?.(constants.NGHTTP2_NO_ERROR);
|
|
348
|
+
incoming.stream?.close?.(constants$1.NGHTTP2_NO_ERROR);
|
|
324
349
|
} catch {}
|
|
325
350
|
return;
|
|
326
351
|
}
|
|
@@ -2497,17 +2522,17 @@ var colorStatus = async (status) => {
|
|
|
2497
2522
|
}
|
|
2498
2523
|
return `${status}`;
|
|
2499
2524
|
};
|
|
2500
|
-
async function log(fn, prefix, method, path, status = 0, elapsed) {
|
|
2525
|
+
async function log$1(fn, prefix, method, path, status = 0, elapsed) {
|
|
2501
2526
|
fn(prefix === "<--" ? `${prefix} ${method} ${path}` : `${prefix} ${method} ${path} ${await colorStatus(status)} ${elapsed}`);
|
|
2502
2527
|
}
|
|
2503
|
-
var logger = (fn = console.log) => {
|
|
2528
|
+
var logger$1 = (fn = console.log) => {
|
|
2504
2529
|
return async function logger2(c, next) {
|
|
2505
2530
|
const { method, url } = c.req;
|
|
2506
2531
|
const path = url.slice(url.indexOf("/", 8));
|
|
2507
|
-
await log(fn, "<--", method, path);
|
|
2532
|
+
await log$1(fn, "<--", method, path);
|
|
2508
2533
|
const start = Date.now();
|
|
2509
2534
|
await next();
|
|
2510
|
-
await log(fn, "-->", method, path, c.res.status, time(start));
|
|
2535
|
+
await log$1(fn, "-->", method, path, c.res.status, time(start));
|
|
2511
2536
|
};
|
|
2512
2537
|
};
|
|
2513
2538
|
//#endregion
|
|
@@ -3719,6 +3744,109 @@ function mapRelationalRow(tablesConfig, tableConfig, row, buildQueryResultSelect
|
|
|
3719
3744
|
return result;
|
|
3720
3745
|
}
|
|
3721
3746
|
//#endregion
|
|
3747
|
+
//#region node_modules/hono/dist/helper/factory/index.js
|
|
3748
|
+
var createMiddleware = (middleware) => middleware;
|
|
3749
|
+
//#endregion
|
|
3750
|
+
//#region node_modules/hono/dist/utils/cookie.js
|
|
3751
|
+
var validCookieNameRegEx = /^[\w!#$%&'*.^`|~+-]+$/;
|
|
3752
|
+
var validCookieValueRegEx = /^[ !#-:<-[\]-~]*$/;
|
|
3753
|
+
var parse = (cookie, name) => {
|
|
3754
|
+
if (name && cookie.indexOf(name) === -1) return {};
|
|
3755
|
+
const pairs = cookie.trim().split(";");
|
|
3756
|
+
const parsedCookie = {};
|
|
3757
|
+
for (let pairStr of pairs) {
|
|
3758
|
+
pairStr = pairStr.trim();
|
|
3759
|
+
const valueStartPos = pairStr.indexOf("=");
|
|
3760
|
+
if (valueStartPos === -1) continue;
|
|
3761
|
+
const cookieName = pairStr.substring(0, valueStartPos).trim();
|
|
3762
|
+
if (name && name !== cookieName || !validCookieNameRegEx.test(cookieName)) continue;
|
|
3763
|
+
let cookieValue = pairStr.substring(valueStartPos + 1).trim();
|
|
3764
|
+
if (cookieValue.startsWith("\"") && cookieValue.endsWith("\"")) cookieValue = cookieValue.slice(1, -1);
|
|
3765
|
+
if (validCookieValueRegEx.test(cookieValue)) {
|
|
3766
|
+
parsedCookie[cookieName] = cookieValue.indexOf("%") !== -1 ? tryDecode(cookieValue, decodeURIComponent_) : cookieValue;
|
|
3767
|
+
if (name) break;
|
|
3768
|
+
}
|
|
3769
|
+
}
|
|
3770
|
+
return parsedCookie;
|
|
3771
|
+
};
|
|
3772
|
+
var _serialize = (name, value, opt = {}) => {
|
|
3773
|
+
let cookie = `${name}=${value}`;
|
|
3774
|
+
if (name.startsWith("__Secure-") && !opt.secure) throw new Error("__Secure- Cookie must have Secure attributes");
|
|
3775
|
+
if (name.startsWith("__Host-")) {
|
|
3776
|
+
if (!opt.secure) throw new Error("__Host- Cookie must have Secure attributes");
|
|
3777
|
+
if (opt.path !== "/") throw new Error("__Host- Cookie must have Path attributes with \"/\"");
|
|
3778
|
+
if (opt.domain) throw new Error("__Host- Cookie must not have Domain attributes");
|
|
3779
|
+
}
|
|
3780
|
+
if (opt && typeof opt.maxAge === "number" && opt.maxAge >= 0) {
|
|
3781
|
+
if (opt.maxAge > 3456e4) throw new Error("Cookies Max-Age SHOULD NOT be greater than 400 days (34560000 seconds) in duration.");
|
|
3782
|
+
cookie += `; Max-Age=${opt.maxAge | 0}`;
|
|
3783
|
+
}
|
|
3784
|
+
if (opt.domain && opt.prefix !== "host") cookie += `; Domain=${opt.domain}`;
|
|
3785
|
+
if (opt.path) cookie += `; Path=${opt.path}`;
|
|
3786
|
+
if (opt.expires) {
|
|
3787
|
+
if (opt.expires.getTime() - Date.now() > 3456e7) throw new Error("Cookies Expires SHOULD NOT be greater than 400 days (34560000 seconds) in the future.");
|
|
3788
|
+
cookie += `; Expires=${opt.expires.toUTCString()}`;
|
|
3789
|
+
}
|
|
3790
|
+
if (opt.httpOnly) cookie += "; HttpOnly";
|
|
3791
|
+
if (opt.secure) cookie += "; Secure";
|
|
3792
|
+
if (opt.sameSite) cookie += `; SameSite=${opt.sameSite.charAt(0).toUpperCase() + opt.sameSite.slice(1)}`;
|
|
3793
|
+
if (opt.priority) cookie += `; Priority=${opt.priority.charAt(0).toUpperCase() + opt.priority.slice(1)}`;
|
|
3794
|
+
if (opt.partitioned) {
|
|
3795
|
+
if (!opt.secure) throw new Error("Partitioned Cookie must have Secure attributes");
|
|
3796
|
+
cookie += "; Partitioned";
|
|
3797
|
+
}
|
|
3798
|
+
return cookie;
|
|
3799
|
+
};
|
|
3800
|
+
var serialize = (name, value, opt) => {
|
|
3801
|
+
value = encodeURIComponent(value);
|
|
3802
|
+
return _serialize(name, value, opt);
|
|
3803
|
+
};
|
|
3804
|
+
//#endregion
|
|
3805
|
+
//#region node_modules/hono/dist/helper/cookie/index.js
|
|
3806
|
+
var getCookie = (c, key, prefix) => {
|
|
3807
|
+
const cookie = c.req.raw.headers.get("Cookie");
|
|
3808
|
+
if (typeof key === "string") {
|
|
3809
|
+
if (!cookie) return;
|
|
3810
|
+
let finalKey = key;
|
|
3811
|
+
if (prefix === "secure") finalKey = "__Secure-" + key;
|
|
3812
|
+
else if (prefix === "host") finalKey = "__Host-" + key;
|
|
3813
|
+
return parse(cookie, finalKey)[finalKey];
|
|
3814
|
+
}
|
|
3815
|
+
if (!cookie) return {};
|
|
3816
|
+
return parse(cookie);
|
|
3817
|
+
};
|
|
3818
|
+
var generateCookie = (name, value, opt) => {
|
|
3819
|
+
let cookie;
|
|
3820
|
+
if (opt?.prefix === "secure") cookie = serialize("__Secure-" + name, value, {
|
|
3821
|
+
path: "/",
|
|
3822
|
+
...opt,
|
|
3823
|
+
secure: true
|
|
3824
|
+
});
|
|
3825
|
+
else if (opt?.prefix === "host") cookie = serialize("__Host-" + name, value, {
|
|
3826
|
+
...opt,
|
|
3827
|
+
path: "/",
|
|
3828
|
+
secure: true,
|
|
3829
|
+
domain: void 0
|
|
3830
|
+
});
|
|
3831
|
+
else cookie = serialize(name, value, {
|
|
3832
|
+
path: "/",
|
|
3833
|
+
...opt
|
|
3834
|
+
});
|
|
3835
|
+
return cookie;
|
|
3836
|
+
};
|
|
3837
|
+
var setCookie = (c, name, value, opt) => {
|
|
3838
|
+
const cookie = generateCookie(name, value, opt);
|
|
3839
|
+
c.header("Set-Cookie", cookie, { append: true });
|
|
3840
|
+
};
|
|
3841
|
+
var deleteCookie = (c, name, opt) => {
|
|
3842
|
+
const deletedCookie = getCookie(c, name, opt?.prefix);
|
|
3843
|
+
setCookie(c, name, "", {
|
|
3844
|
+
...opt,
|
|
3845
|
+
maxAge: 0
|
|
3846
|
+
});
|
|
3847
|
+
return deletedCookie;
|
|
3848
|
+
};
|
|
3849
|
+
//#endregion
|
|
3722
3850
|
//#region node_modules/drizzle-orm/selection-proxy.js
|
|
3723
3851
|
var SelectionProxyHandler = class SelectionProxyHandler {
|
|
3724
3852
|
static [entityKind] = "SelectionProxyHandler";
|
|
@@ -4269,6 +4397,55 @@ const sqliteTable = (name, columns, extraConfig) => {
|
|
|
4269
4397
|
return sqliteTableBase(name, columns, extraConfig);
|
|
4270
4398
|
};
|
|
4271
4399
|
//#endregion
|
|
4400
|
+
//#region node_modules/drizzle-orm/sqlite-core/indexes.js
|
|
4401
|
+
var IndexBuilderOn = class {
|
|
4402
|
+
constructor(name, unique) {
|
|
4403
|
+
this.name = name;
|
|
4404
|
+
this.unique = unique;
|
|
4405
|
+
}
|
|
4406
|
+
static [entityKind] = "SQLiteIndexBuilderOn";
|
|
4407
|
+
on(...columns) {
|
|
4408
|
+
return new IndexBuilder(this.name, columns, this.unique);
|
|
4409
|
+
}
|
|
4410
|
+
};
|
|
4411
|
+
var IndexBuilder = class {
|
|
4412
|
+
static [entityKind] = "SQLiteIndexBuilder";
|
|
4413
|
+
/** @internal */
|
|
4414
|
+
config;
|
|
4415
|
+
constructor(name, columns, unique) {
|
|
4416
|
+
this.config = {
|
|
4417
|
+
name,
|
|
4418
|
+
columns,
|
|
4419
|
+
unique,
|
|
4420
|
+
where: void 0
|
|
4421
|
+
};
|
|
4422
|
+
}
|
|
4423
|
+
/**
|
|
4424
|
+
* Condition for partial index.
|
|
4425
|
+
*/
|
|
4426
|
+
where(condition) {
|
|
4427
|
+
this.config.where = condition;
|
|
4428
|
+
return this;
|
|
4429
|
+
}
|
|
4430
|
+
/** @internal */
|
|
4431
|
+
build(table) {
|
|
4432
|
+
return new Index(this.config, table);
|
|
4433
|
+
}
|
|
4434
|
+
};
|
|
4435
|
+
var Index = class {
|
|
4436
|
+
static [entityKind] = "SQLiteIndex";
|
|
4437
|
+
config;
|
|
4438
|
+
constructor(config, table) {
|
|
4439
|
+
this.config = {
|
|
4440
|
+
...config,
|
|
4441
|
+
table
|
|
4442
|
+
};
|
|
4443
|
+
}
|
|
4444
|
+
};
|
|
4445
|
+
function uniqueIndex(name) {
|
|
4446
|
+
return new IndexBuilderOn(name, true);
|
|
4447
|
+
}
|
|
4448
|
+
//#endregion
|
|
4272
4449
|
//#region node_modules/drizzle-orm/sqlite-core/utils.js
|
|
4273
4450
|
function extractUsedTable(table) {
|
|
4274
4451
|
if (is(table, SQLiteTable)) return [`${table[Table.Symbol.BaseName]}`];
|
|
@@ -6633,13 +6810,26 @@ async function saveConfig(config) {
|
|
|
6633
6810
|
//#endregion
|
|
6634
6811
|
//#region src/database/schema.ts
|
|
6635
6812
|
var schema_exports = /* @__PURE__ */ __exportAll({
|
|
6813
|
+
authConfig: () => authConfig,
|
|
6636
6814
|
episodes: () => episodes,
|
|
6815
|
+
favorites: () => favorites,
|
|
6637
6816
|
libraries: () => libraries,
|
|
6638
6817
|
movies: () => movies,
|
|
6818
|
+
profiles: () => profiles,
|
|
6819
|
+
scanJobs: () => scanJobs,
|
|
6639
6820
|
seasons: () => seasons,
|
|
6821
|
+
sessions: () => sessions$1,
|
|
6822
|
+
settings: () => settings,
|
|
6640
6823
|
shows: () => shows,
|
|
6824
|
+
subtitles: () => subtitles,
|
|
6641
6825
|
watchProgress: () => watchProgress
|
|
6642
6826
|
});
|
|
6827
|
+
const profiles = sqliteTable("profiles", {
|
|
6828
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6829
|
+
name: text("name").notNull().unique(),
|
|
6830
|
+
emoji: text("emoji").notNull(),
|
|
6831
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull()
|
|
6832
|
+
});
|
|
6643
6833
|
const libraries = sqliteTable("libraries", {
|
|
6644
6834
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6645
6835
|
name: text("name").notNull(),
|
|
@@ -6709,23 +6899,624 @@ const episodes = sqliteTable("episodes", {
|
|
|
6709
6899
|
});
|
|
6710
6900
|
const watchProgress = sqliteTable("watch_progress", {
|
|
6711
6901
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6712
|
-
|
|
6713
|
-
|
|
6902
|
+
profileId: integer("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
|
|
6903
|
+
movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
|
|
6904
|
+
episodeId: integer("episode_id").references(() => episodes.id, { onDelete: "cascade" }),
|
|
6714
6905
|
currentTime: integer("current_time").notNull(),
|
|
6715
6906
|
duration: integer("duration"),
|
|
6716
6907
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
|
|
6908
|
+
}, (table) => [uniqueIndex("watch_progress_profile_movie_idx").on(table.profileId, table.movieId), uniqueIndex("watch_progress_profile_episode_idx").on(table.profileId, table.episodeId)]);
|
|
6909
|
+
const favorites = sqliteTable("favorites", {
|
|
6910
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6911
|
+
profileId: integer("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
|
|
6912
|
+
movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
|
|
6913
|
+
showId: integer("show_id").references(() => shows.id, { onDelete: "cascade" }),
|
|
6914
|
+
createdAt: integer("created_at", { mode: "timestamp" }).notNull()
|
|
6915
|
+
}, (table) => [uniqueIndex("favorites_profile_movie_idx").on(table.profileId, table.movieId), uniqueIndex("favorites_profile_show_idx").on(table.profileId, table.showId)]);
|
|
6916
|
+
const authConfig = sqliteTable("auth_config", {
|
|
6917
|
+
id: integer("id").primaryKey(),
|
|
6918
|
+
passwordHash: text("password_hash"),
|
|
6919
|
+
updatedAt: integer("updated_at").notNull()
|
|
6920
|
+
});
|
|
6921
|
+
const sessions$1 = sqliteTable("sessions", {
|
|
6922
|
+
id: text("id").primaryKey(),
|
|
6923
|
+
createdAt: integer("created_at").notNull(),
|
|
6924
|
+
expiresAt: integer("expires_at").notNull(),
|
|
6925
|
+
lastSeenAt: integer("last_seen_at").notNull(),
|
|
6926
|
+
userAgent: text("user_agent")
|
|
6927
|
+
});
|
|
6928
|
+
const settings = sqliteTable("settings", {
|
|
6929
|
+
key: text("key").primaryKey(),
|
|
6930
|
+
value: text("value").notNull(),
|
|
6931
|
+
updatedAt: integer("updated_at").notNull()
|
|
6932
|
+
});
|
|
6933
|
+
const scanJobs = sqliteTable("scan_jobs", {
|
|
6934
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6935
|
+
startedAt: integer("started_at", { mode: "timestamp" }).notNull(),
|
|
6936
|
+
endedAt: integer("ended_at", { mode: "timestamp" }),
|
|
6937
|
+
status: text("status").notNull(),
|
|
6938
|
+
added: integer("added").notNull().default(0),
|
|
6939
|
+
updated: integer("updated").notNull().default(0),
|
|
6940
|
+
total: integer("total").notNull().default(0),
|
|
6941
|
+
errorMessage: text("error_message")
|
|
6942
|
+
});
|
|
6943
|
+
const subtitles = sqliteTable("subtitles", {
|
|
6944
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6945
|
+
movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
|
|
6946
|
+
episodeId: integer("episode_id").references(() => episodes.id, { onDelete: "cascade" }),
|
|
6947
|
+
filePath: text("file_path").notNull(),
|
|
6948
|
+
language: text("language").notNull(),
|
|
6949
|
+
format: text("format").notNull()
|
|
6717
6950
|
});
|
|
6718
6951
|
//#endregion
|
|
6719
6952
|
//#region src/database/index.ts
|
|
6720
6953
|
ensureConfigDirectory();
|
|
6721
6954
|
const sqlite = new Database(databasePath);
|
|
6955
|
+
sqlite.pragma("foreign_keys = ON");
|
|
6722
6956
|
const db = drizzle(sqlite, { schema: schema_exports });
|
|
6723
6957
|
const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
|
|
6724
6958
|
migrate(db, { migrationsFolder: path.resolve(__dirname$2, "../../drizzle") });
|
|
6725
6959
|
//#endregion
|
|
6960
|
+
//#region node_modules/tiny-invariant/dist/esm/tiny-invariant.js
|
|
6961
|
+
var import_dayjs_min = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
6962
|
+
(function(t, e) {
|
|
6963
|
+
"object" == typeof exports && "undefined" != typeof module ? module.exports = e() : "function" == typeof define && define.amd ? define(e) : (t = "undefined" != typeof globalThis ? globalThis : t || self).dayjs = e();
|
|
6964
|
+
})(exports, (function() {
|
|
6965
|
+
"use strict";
|
|
6966
|
+
var t = 1e3, e = 6e4, n = 36e5, r = "millisecond", i = "second", s = "minute", u = "hour", a = "day", o = "week", c = "month", f = "quarter", h = "year", d = "date", l = "Invalid Date", $ = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/, y = /\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g, M = {
|
|
6967
|
+
name: "en",
|
|
6968
|
+
weekdays: "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),
|
|
6969
|
+
months: "January_February_March_April_May_June_July_August_September_October_November_December".split("_"),
|
|
6970
|
+
ordinal: function(t) {
|
|
6971
|
+
var e = [
|
|
6972
|
+
"th",
|
|
6973
|
+
"st",
|
|
6974
|
+
"nd",
|
|
6975
|
+
"rd"
|
|
6976
|
+
], n = t % 100;
|
|
6977
|
+
return "[" + t + (e[(n - 20) % 10] || e[n] || e[0]) + "]";
|
|
6978
|
+
}
|
|
6979
|
+
}, m = function(t, e, n) {
|
|
6980
|
+
var r = String(t);
|
|
6981
|
+
return !r || r.length >= e ? t : "" + Array(e + 1 - r.length).join(n) + t;
|
|
6982
|
+
}, v = {
|
|
6983
|
+
s: m,
|
|
6984
|
+
z: function(t) {
|
|
6985
|
+
var e = -t.utcOffset(), n = Math.abs(e), r = Math.floor(n / 60), i = n % 60;
|
|
6986
|
+
return (e <= 0 ? "+" : "-") + m(r, 2, "0") + ":" + m(i, 2, "0");
|
|
6987
|
+
},
|
|
6988
|
+
m: function t(e, n) {
|
|
6989
|
+
if (e.date() < n.date()) return -t(n, e);
|
|
6990
|
+
var r = 12 * (n.year() - e.year()) + (n.month() - e.month()), i = e.clone().add(r, c), s = n - i < 0, u = e.clone().add(r + (s ? -1 : 1), c);
|
|
6991
|
+
return +(-(r + (n - i) / (s ? i - u : u - i)) || 0);
|
|
6992
|
+
},
|
|
6993
|
+
a: function(t) {
|
|
6994
|
+
return t < 0 ? Math.ceil(t) || 0 : Math.floor(t);
|
|
6995
|
+
},
|
|
6996
|
+
p: function(t) {
|
|
6997
|
+
return {
|
|
6998
|
+
M: c,
|
|
6999
|
+
y: h,
|
|
7000
|
+
w: o,
|
|
7001
|
+
d: a,
|
|
7002
|
+
D: d,
|
|
7003
|
+
h: u,
|
|
7004
|
+
m: s,
|
|
7005
|
+
s: i,
|
|
7006
|
+
ms: r,
|
|
7007
|
+
Q: f
|
|
7008
|
+
}[t] || String(t || "").toLowerCase().replace(/s$/, "");
|
|
7009
|
+
},
|
|
7010
|
+
u: function(t) {
|
|
7011
|
+
return void 0 === t;
|
|
7012
|
+
}
|
|
7013
|
+
}, g = "en", D = {};
|
|
7014
|
+
D[g] = M;
|
|
7015
|
+
var p = "$isDayjsObject", S = function(t) {
|
|
7016
|
+
return t instanceof _ || !(!t || !t[p]);
|
|
7017
|
+
}, w = function t(e, n, r) {
|
|
7018
|
+
var i;
|
|
7019
|
+
if (!e) return g;
|
|
7020
|
+
if ("string" == typeof e) {
|
|
7021
|
+
var s = e.toLowerCase();
|
|
7022
|
+
D[s] && (i = s), n && (D[s] = n, i = s);
|
|
7023
|
+
var u = e.split("-");
|
|
7024
|
+
if (!i && u.length > 1) return t(u[0]);
|
|
7025
|
+
} else {
|
|
7026
|
+
var a = e.name;
|
|
7027
|
+
D[a] = e, i = a;
|
|
7028
|
+
}
|
|
7029
|
+
return !r && i && (g = i), i || !r && g;
|
|
7030
|
+
}, O = function(t, e) {
|
|
7031
|
+
if (S(t)) return t.clone();
|
|
7032
|
+
var n = "object" == typeof e ? e : {};
|
|
7033
|
+
return n.date = t, n.args = arguments, new _(n);
|
|
7034
|
+
}, b = v;
|
|
7035
|
+
b.l = w, b.i = S, b.w = function(t, e) {
|
|
7036
|
+
return O(t, {
|
|
7037
|
+
locale: e.$L,
|
|
7038
|
+
utc: e.$u,
|
|
7039
|
+
x: e.$x,
|
|
7040
|
+
$offset: e.$offset
|
|
7041
|
+
});
|
|
7042
|
+
};
|
|
7043
|
+
var _ = function() {
|
|
7044
|
+
function M(t) {
|
|
7045
|
+
this.$L = w(t.locale, null, !0), this.parse(t), this.$x = this.$x || t.x || {}, this[p] = !0;
|
|
7046
|
+
}
|
|
7047
|
+
var m = M.prototype;
|
|
7048
|
+
return m.parse = function(t) {
|
|
7049
|
+
this.$d = function(t) {
|
|
7050
|
+
var e = t.date, n = t.utc;
|
|
7051
|
+
if (null === e) return /* @__PURE__ */ new Date(NaN);
|
|
7052
|
+
if (b.u(e)) return /* @__PURE__ */ new Date();
|
|
7053
|
+
if (e instanceof Date) return new Date(e);
|
|
7054
|
+
if ("string" == typeof e && !/Z$/i.test(e)) {
|
|
7055
|
+
var r = e.match($);
|
|
7056
|
+
if (r) {
|
|
7057
|
+
var i = r[2] - 1 || 0, s = (r[7] || "0").substring(0, 3);
|
|
7058
|
+
return n ? new Date(Date.UTC(r[1], i, r[3] || 1, r[4] || 0, r[5] || 0, r[6] || 0, s)) : new Date(r[1], i, r[3] || 1, r[4] || 0, r[5] || 0, r[6] || 0, s);
|
|
7059
|
+
}
|
|
7060
|
+
}
|
|
7061
|
+
return new Date(e);
|
|
7062
|
+
}(t), this.init();
|
|
7063
|
+
}, m.init = function() {
|
|
7064
|
+
var t = this.$d;
|
|
7065
|
+
this.$y = t.getFullYear(), this.$M = t.getMonth(), this.$D = t.getDate(), this.$W = t.getDay(), this.$H = t.getHours(), this.$m = t.getMinutes(), this.$s = t.getSeconds(), this.$ms = t.getMilliseconds();
|
|
7066
|
+
}, m.$utils = function() {
|
|
7067
|
+
return b;
|
|
7068
|
+
}, m.isValid = function() {
|
|
7069
|
+
return !(this.$d.toString() === l);
|
|
7070
|
+
}, m.isSame = function(t, e) {
|
|
7071
|
+
var n = O(t);
|
|
7072
|
+
return this.startOf(e) <= n && n <= this.endOf(e);
|
|
7073
|
+
}, m.isAfter = function(t, e) {
|
|
7074
|
+
return O(t) < this.startOf(e);
|
|
7075
|
+
}, m.isBefore = function(t, e) {
|
|
7076
|
+
return this.endOf(e) < O(t);
|
|
7077
|
+
}, m.$g = function(t, e, n) {
|
|
7078
|
+
return b.u(t) ? this[e] : this.set(n, t);
|
|
7079
|
+
}, m.unix = function() {
|
|
7080
|
+
return Math.floor(this.valueOf() / 1e3);
|
|
7081
|
+
}, m.valueOf = function() {
|
|
7082
|
+
return this.$d.getTime();
|
|
7083
|
+
}, m.startOf = function(t, e) {
|
|
7084
|
+
var n = this, r = !!b.u(e) || e, f = b.p(t), l = function(t, e) {
|
|
7085
|
+
var i = b.w(n.$u ? Date.UTC(n.$y, e, t) : new Date(n.$y, e, t), n);
|
|
7086
|
+
return r ? i : i.endOf(a);
|
|
7087
|
+
}, $ = function(t, e) {
|
|
7088
|
+
return b.w(n.toDate()[t].apply(n.toDate("s"), (r ? [
|
|
7089
|
+
0,
|
|
7090
|
+
0,
|
|
7091
|
+
0,
|
|
7092
|
+
0
|
|
7093
|
+
] : [
|
|
7094
|
+
23,
|
|
7095
|
+
59,
|
|
7096
|
+
59,
|
|
7097
|
+
999
|
|
7098
|
+
]).slice(e)), n);
|
|
7099
|
+
}, y = this.$W, M = this.$M, m = this.$D, v = "set" + (this.$u ? "UTC" : "");
|
|
7100
|
+
switch (f) {
|
|
7101
|
+
case h: return r ? l(1, 0) : l(31, 11);
|
|
7102
|
+
case c: return r ? l(1, M) : l(0, M + 1);
|
|
7103
|
+
case o:
|
|
7104
|
+
var g = this.$locale().weekStart || 0, D = (y < g ? y + 7 : y) - g;
|
|
7105
|
+
return l(r ? m - D : m + (6 - D), M);
|
|
7106
|
+
case a:
|
|
7107
|
+
case d: return $(v + "Hours", 0);
|
|
7108
|
+
case u: return $(v + "Minutes", 1);
|
|
7109
|
+
case s: return $(v + "Seconds", 2);
|
|
7110
|
+
case i: return $(v + "Milliseconds", 3);
|
|
7111
|
+
default: return this.clone();
|
|
7112
|
+
}
|
|
7113
|
+
}, m.endOf = function(t) {
|
|
7114
|
+
return this.startOf(t, !1);
|
|
7115
|
+
}, m.$set = function(t, e) {
|
|
7116
|
+
var n, o = b.p(t), f = "set" + (this.$u ? "UTC" : ""), l = (n = {}, n[a] = f + "Date", n[d] = f + "Date", n[c] = f + "Month", n[h] = f + "FullYear", n[u] = f + "Hours", n[s] = f + "Minutes", n[i] = f + "Seconds", n[r] = f + "Milliseconds", n)[o], $ = o === a ? this.$D + (e - this.$W) : e;
|
|
7117
|
+
if (o === c || o === h) {
|
|
7118
|
+
var y = this.clone().set(d, 1);
|
|
7119
|
+
y.$d[l]($), y.init(), this.$d = y.set(d, Math.min(this.$D, y.daysInMonth())).$d;
|
|
7120
|
+
} else l && this.$d[l]($);
|
|
7121
|
+
return this.init(), this;
|
|
7122
|
+
}, m.set = function(t, e) {
|
|
7123
|
+
return this.clone().$set(t, e);
|
|
7124
|
+
}, m.get = function(t) {
|
|
7125
|
+
return this[b.p(t)]();
|
|
7126
|
+
}, m.add = function(r, f) {
|
|
7127
|
+
var d, l = this;
|
|
7128
|
+
r = Number(r);
|
|
7129
|
+
var $ = b.p(f), y = function(t) {
|
|
7130
|
+
var e = O(l);
|
|
7131
|
+
return b.w(e.date(e.date() + Math.round(t * r)), l);
|
|
7132
|
+
};
|
|
7133
|
+
if ($ === c) return this.set(c, this.$M + r);
|
|
7134
|
+
if ($ === h) return this.set(h, this.$y + r);
|
|
7135
|
+
if ($ === a) return y(1);
|
|
7136
|
+
if ($ === o) return y(7);
|
|
7137
|
+
var M = (d = {}, d[s] = e, d[u] = n, d[i] = t, d)[$] || 1, m = this.$d.getTime() + r * M;
|
|
7138
|
+
return b.w(m, this);
|
|
7139
|
+
}, m.subtract = function(t, e) {
|
|
7140
|
+
return this.add(-1 * t, e);
|
|
7141
|
+
}, m.format = function(t) {
|
|
7142
|
+
var e = this, n = this.$locale();
|
|
7143
|
+
if (!this.isValid()) return n.invalidDate || l;
|
|
7144
|
+
var r = t || "YYYY-MM-DDTHH:mm:ssZ", i = b.z(this), s = this.$H, u = this.$m, a = this.$M, o = n.weekdays, c = n.months, f = n.meridiem, h = function(t, n, i, s) {
|
|
7145
|
+
return t && (t[n] || t(e, r)) || i[n].slice(0, s);
|
|
7146
|
+
}, d = function(t) {
|
|
7147
|
+
return b.s(s % 12 || 12, t, "0");
|
|
7148
|
+
}, $ = f || function(t, e, n) {
|
|
7149
|
+
var r = t < 12 ? "AM" : "PM";
|
|
7150
|
+
return n ? r.toLowerCase() : r;
|
|
7151
|
+
};
|
|
7152
|
+
return r.replace(y, (function(t, r) {
|
|
7153
|
+
return r || function(t) {
|
|
7154
|
+
switch (t) {
|
|
7155
|
+
case "YY": return String(e.$y).slice(-2);
|
|
7156
|
+
case "YYYY": return b.s(e.$y, 4, "0");
|
|
7157
|
+
case "M": return a + 1;
|
|
7158
|
+
case "MM": return b.s(a + 1, 2, "0");
|
|
7159
|
+
case "MMM": return h(n.monthsShort, a, c, 3);
|
|
7160
|
+
case "MMMM": return h(c, a);
|
|
7161
|
+
case "D": return e.$D;
|
|
7162
|
+
case "DD": return b.s(e.$D, 2, "0");
|
|
7163
|
+
case "d": return String(e.$W);
|
|
7164
|
+
case "dd": return h(n.weekdaysMin, e.$W, o, 2);
|
|
7165
|
+
case "ddd": return h(n.weekdaysShort, e.$W, o, 3);
|
|
7166
|
+
case "dddd": return o[e.$W];
|
|
7167
|
+
case "H": return String(s);
|
|
7168
|
+
case "HH": return b.s(s, 2, "0");
|
|
7169
|
+
case "h": return d(1);
|
|
7170
|
+
case "hh": return d(2);
|
|
7171
|
+
case "a": return $(s, u, !0);
|
|
7172
|
+
case "A": return $(s, u, !1);
|
|
7173
|
+
case "m": return String(u);
|
|
7174
|
+
case "mm": return b.s(u, 2, "0");
|
|
7175
|
+
case "s": return String(e.$s);
|
|
7176
|
+
case "ss": return b.s(e.$s, 2, "0");
|
|
7177
|
+
case "SSS": return b.s(e.$ms, 3, "0");
|
|
7178
|
+
case "Z": return i;
|
|
7179
|
+
}
|
|
7180
|
+
return null;
|
|
7181
|
+
}(t) || i.replace(":", "");
|
|
7182
|
+
}));
|
|
7183
|
+
}, m.utcOffset = function() {
|
|
7184
|
+
return 15 * -Math.round(this.$d.getTimezoneOffset() / 15);
|
|
7185
|
+
}, m.diff = function(r, d, l) {
|
|
7186
|
+
var $, y = this, M = b.p(d), m = O(r), v = (m.utcOffset() - this.utcOffset()) * e, g = this - m, D = function() {
|
|
7187
|
+
return b.m(y, m);
|
|
7188
|
+
};
|
|
7189
|
+
switch (M) {
|
|
7190
|
+
case h:
|
|
7191
|
+
$ = D() / 12;
|
|
7192
|
+
break;
|
|
7193
|
+
case c:
|
|
7194
|
+
$ = D();
|
|
7195
|
+
break;
|
|
7196
|
+
case f:
|
|
7197
|
+
$ = D() / 3;
|
|
7198
|
+
break;
|
|
7199
|
+
case o:
|
|
7200
|
+
$ = (g - v) / 6048e5;
|
|
7201
|
+
break;
|
|
7202
|
+
case a:
|
|
7203
|
+
$ = (g - v) / 864e5;
|
|
7204
|
+
break;
|
|
7205
|
+
case u:
|
|
7206
|
+
$ = g / n;
|
|
7207
|
+
break;
|
|
7208
|
+
case s:
|
|
7209
|
+
$ = g / e;
|
|
7210
|
+
break;
|
|
7211
|
+
case i:
|
|
7212
|
+
$ = g / t;
|
|
7213
|
+
break;
|
|
7214
|
+
default: $ = g;
|
|
7215
|
+
}
|
|
7216
|
+
return l ? $ : b.a($);
|
|
7217
|
+
}, m.daysInMonth = function() {
|
|
7218
|
+
return this.endOf(c).$D;
|
|
7219
|
+
}, m.$locale = function() {
|
|
7220
|
+
return D[this.$L];
|
|
7221
|
+
}, m.locale = function(t, e) {
|
|
7222
|
+
if (!t) return this.$L;
|
|
7223
|
+
var n = this.clone(), r = w(t, e, !0);
|
|
7224
|
+
return r && (n.$L = r), n;
|
|
7225
|
+
}, m.clone = function() {
|
|
7226
|
+
return b.w(this.$d, this);
|
|
7227
|
+
}, m.toDate = function() {
|
|
7228
|
+
return new Date(this.valueOf());
|
|
7229
|
+
}, m.toJSON = function() {
|
|
7230
|
+
return this.isValid() ? this.toISOString() : null;
|
|
7231
|
+
}, m.toISOString = function() {
|
|
7232
|
+
return this.$d.toISOString();
|
|
7233
|
+
}, m.toString = function() {
|
|
7234
|
+
return this.$d.toUTCString();
|
|
7235
|
+
}, M;
|
|
7236
|
+
}(), k = _.prototype;
|
|
7237
|
+
return O.prototype = k, [
|
|
7238
|
+
["$ms", r],
|
|
7239
|
+
["$s", i],
|
|
7240
|
+
["$m", s],
|
|
7241
|
+
["$H", u],
|
|
7242
|
+
["$W", a],
|
|
7243
|
+
["$M", c],
|
|
7244
|
+
["$y", h],
|
|
7245
|
+
["$D", d]
|
|
7246
|
+
].forEach((function(t) {
|
|
7247
|
+
k[t[1]] = function(e) {
|
|
7248
|
+
return this.$g(e, t[0], t[1]);
|
|
7249
|
+
};
|
|
7250
|
+
})), O.extend = function(t, e) {
|
|
7251
|
+
return t.$i || (t(e, _, O), t.$i = !0), O;
|
|
7252
|
+
}, O.locale = w, O.isDayjs = S, O.unix = function(t) {
|
|
7253
|
+
return O(1e3 * t);
|
|
7254
|
+
}, O.en = D[g], O.Ls = D, O.p = {}, O;
|
|
7255
|
+
}));
|
|
7256
|
+
})))(), 1);
|
|
7257
|
+
var isProduction = process.env.NODE_ENV === "production";
|
|
7258
|
+
var prefix = "Invariant failed";
|
|
7259
|
+
function invariant(condition, message) {
|
|
7260
|
+
if (condition) return;
|
|
7261
|
+
if (isProduction) throw new Error(prefix);
|
|
7262
|
+
var provided = typeof message === "function" ? message() : message;
|
|
7263
|
+
var value = provided ? "".concat(prefix, ": ").concat(provided) : prefix;
|
|
7264
|
+
throw new Error(value);
|
|
7265
|
+
}
|
|
7266
|
+
//#endregion
|
|
7267
|
+
//#region src/api/routes/auth.ts
|
|
7268
|
+
const scryptAsync = promisify(scrypt);
|
|
7269
|
+
const sessionCookieName = "jukebox_session";
|
|
7270
|
+
const rateLimitWindowMs = 900 * 1e3;
|
|
7271
|
+
const rateLimitBuckets = /* @__PURE__ */ new Map();
|
|
7272
|
+
function clientIp(headers) {
|
|
7273
|
+
const forwarded = headers.get("x-forwarded-for");
|
|
7274
|
+
if (forwarded) {
|
|
7275
|
+
const first = forwarded.split(",")[0]?.trim();
|
|
7276
|
+
if (first) return first;
|
|
7277
|
+
}
|
|
7278
|
+
return headers.get("x-real-ip") ?? "unknown";
|
|
7279
|
+
}
|
|
7280
|
+
function checkRateLimit(ip) {
|
|
7281
|
+
const now = Date.now();
|
|
7282
|
+
const cutoff = now - rateLimitWindowMs;
|
|
7283
|
+
const entry = rateLimitBuckets.get(ip) ?? { attempts: [] };
|
|
7284
|
+
entry.attempts = entry.attempts.filter((time) => time > cutoff);
|
|
7285
|
+
if (entry.attempts.length >= 5) {
|
|
7286
|
+
const retryAfterMs = (entry.attempts[0] ?? now) + rateLimitWindowMs - now;
|
|
7287
|
+
const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1e3));
|
|
7288
|
+
rateLimitBuckets.set(ip, entry);
|
|
7289
|
+
return {
|
|
7290
|
+
allowed: false,
|
|
7291
|
+
retryAfterSeconds
|
|
7292
|
+
};
|
|
7293
|
+
}
|
|
7294
|
+
rateLimitBuckets.set(ip, entry);
|
|
7295
|
+
return {
|
|
7296
|
+
allowed: true,
|
|
7297
|
+
retryAfterSeconds: 0
|
|
7298
|
+
};
|
|
7299
|
+
}
|
|
7300
|
+
function recordFailedAttempt(ip) {
|
|
7301
|
+
const entry = rateLimitBuckets.get(ip) ?? { attempts: [] };
|
|
7302
|
+
entry.attempts.push(Date.now());
|
|
7303
|
+
rateLimitBuckets.set(ip, entry);
|
|
7304
|
+
}
|
|
7305
|
+
function clearAttempts(ip) {
|
|
7306
|
+
rateLimitBuckets.delete(ip);
|
|
7307
|
+
}
|
|
7308
|
+
async function loadAuthConfig() {
|
|
7309
|
+
const [existing] = await db.select().from(authConfig).where(eq(authConfig.id, 1)).limit(1);
|
|
7310
|
+
if (existing) return existing;
|
|
7311
|
+
const [created] = await db.insert(authConfig).values({
|
|
7312
|
+
id: 1,
|
|
7313
|
+
passwordHash: null,
|
|
7314
|
+
updatedAt: Date.now()
|
|
7315
|
+
}).returning();
|
|
7316
|
+
invariant(created, "Failed to initialize auth config.");
|
|
7317
|
+
return created;
|
|
7318
|
+
}
|
|
7319
|
+
const scryptKeyLength = 64;
|
|
7320
|
+
async function hashPassword(password) {
|
|
7321
|
+
const salt = randomBytes(16);
|
|
7322
|
+
const derived = await scryptAsync(password, salt, scryptKeyLength);
|
|
7323
|
+
return `scrypt$${salt.toString("base64")}$${derived.toString("base64")}`;
|
|
7324
|
+
}
|
|
7325
|
+
async function verifyPassword(password, stored) {
|
|
7326
|
+
const parts = stored.split("$");
|
|
7327
|
+
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
|
7328
|
+
try {
|
|
7329
|
+
const salt = Buffer.from(parts[1] ?? "", "base64");
|
|
7330
|
+
const expected = Buffer.from(parts[2] ?? "", "base64");
|
|
7331
|
+
const derived = await scryptAsync(password, salt, expected.length);
|
|
7332
|
+
if (derived.length !== expected.length) return false;
|
|
7333
|
+
return timingSafeEqual(derived, expected);
|
|
7334
|
+
} catch {
|
|
7335
|
+
return false;
|
|
7336
|
+
}
|
|
7337
|
+
}
|
|
7338
|
+
function createSessionToken() {
|
|
7339
|
+
return randomBytes(32).toString("base64url");
|
|
7340
|
+
}
|
|
7341
|
+
async function createSession(userAgent) {
|
|
7342
|
+
const now = (0, import_dayjs_min.default)();
|
|
7343
|
+
const token = createSessionToken();
|
|
7344
|
+
await db.insert(sessions$1).values({
|
|
7345
|
+
id: token,
|
|
7346
|
+
createdAt: now.valueOf(),
|
|
7347
|
+
expiresAt: now.add(30, "day").valueOf(),
|
|
7348
|
+
lastSeenAt: now.valueOf(),
|
|
7349
|
+
userAgent
|
|
7350
|
+
});
|
|
7351
|
+
return token;
|
|
7352
|
+
}
|
|
7353
|
+
async function deleteAllSessions() {
|
|
7354
|
+
await db.delete(sessions$1);
|
|
7355
|
+
}
|
|
7356
|
+
async function deleteSession(id) {
|
|
7357
|
+
await db.delete(sessions$1).where(eq(sessions$1.id, id));
|
|
7358
|
+
}
|
|
7359
|
+
function sessionCookieOptions() {
|
|
7360
|
+
return {
|
|
7361
|
+
httpOnly: true,
|
|
7362
|
+
maxAge: 720 * 60 * 60,
|
|
7363
|
+
path: "/",
|
|
7364
|
+
sameSite: "Lax",
|
|
7365
|
+
secure: process.env.NODE_ENV === "production"
|
|
7366
|
+
};
|
|
7367
|
+
}
|
|
7368
|
+
const authRoutes = new Hono();
|
|
7369
|
+
authRoutes.get("/status", async (context) => {
|
|
7370
|
+
if (!((await loadAuthConfig()).passwordHash !== null)) return context.json({
|
|
7371
|
+
enabled: false,
|
|
7372
|
+
authenticated: true
|
|
7373
|
+
});
|
|
7374
|
+
const cookie = getCookie(context, sessionCookieName);
|
|
7375
|
+
if (!cookie) return context.json({
|
|
7376
|
+
enabled: true,
|
|
7377
|
+
authenticated: false
|
|
7378
|
+
});
|
|
7379
|
+
const [session] = await db.select().from(sessions$1).where(eq(sessions$1.id, cookie)).limit(1);
|
|
7380
|
+
const authenticated = session !== void 0 && session.expiresAt > Date.now();
|
|
7381
|
+
return context.json({
|
|
7382
|
+
enabled: true,
|
|
7383
|
+
authenticated
|
|
7384
|
+
});
|
|
7385
|
+
});
|
|
7386
|
+
authRoutes.post("/login", async (context) => {
|
|
7387
|
+
const ip = clientIp(context.req.raw.headers);
|
|
7388
|
+
const limit = checkRateLimit(ip);
|
|
7389
|
+
if (!limit.allowed) {
|
|
7390
|
+
const minutes = Math.ceil(limit.retryAfterSeconds / 60);
|
|
7391
|
+
context.header("Retry-After", String(limit.retryAfterSeconds));
|
|
7392
|
+
return context.json({ error: { message: `Too many attempts. Try again in ${minutes} ${minutes === 1 ? "minute" : "minutes"}.` } }, 429);
|
|
7393
|
+
}
|
|
7394
|
+
let body;
|
|
7395
|
+
try {
|
|
7396
|
+
body = await context.req.json();
|
|
7397
|
+
} catch {
|
|
7398
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
7399
|
+
}
|
|
7400
|
+
const password = typeof body.password === "string" ? body.password : "";
|
|
7401
|
+
if (password.length === 0) return context.json({ error: { message: "Incorrect password." } }, 401);
|
|
7402
|
+
const config = await loadAuthConfig();
|
|
7403
|
+
if (config.passwordHash === null) return context.json({ error: { message: "Password login is disabled on this server." } }, 400);
|
|
7404
|
+
if (!await verifyPassword(password, config.passwordHash)) {
|
|
7405
|
+
recordFailedAttempt(ip);
|
|
7406
|
+
return context.json({ error: { message: "Incorrect password." } }, 401);
|
|
7407
|
+
}
|
|
7408
|
+
clearAttempts(ip);
|
|
7409
|
+
setCookie(context, sessionCookieName, await createSession(context.req.header("user-agent") ?? null), sessionCookieOptions());
|
|
7410
|
+
return context.body(null, 204);
|
|
7411
|
+
});
|
|
7412
|
+
authRoutes.post("/logout", async (context) => {
|
|
7413
|
+
const token = getCookie(context, sessionCookieName);
|
|
7414
|
+
if (token) await deleteSession(token);
|
|
7415
|
+
deleteCookie(context, sessionCookieName, { path: "/" });
|
|
7416
|
+
return context.body(null, 204);
|
|
7417
|
+
});
|
|
7418
|
+
authRoutes.post("/password", async (context) => {
|
|
7419
|
+
let body;
|
|
7420
|
+
try {
|
|
7421
|
+
body = await context.req.json();
|
|
7422
|
+
} catch {
|
|
7423
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
7424
|
+
}
|
|
7425
|
+
const newPassword = typeof body.newPassword === "string" ? body.newPassword : "";
|
|
7426
|
+
const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : "";
|
|
7427
|
+
const config = await loadAuthConfig();
|
|
7428
|
+
if (config.passwordHash !== null) {
|
|
7429
|
+
if (!await verifyPassword(currentPassword, config.passwordHash ?? "")) return context.json({ error: { message: "Current password is incorrect." } }, 401);
|
|
7430
|
+
}
|
|
7431
|
+
if (newPassword.length === 0) {
|
|
7432
|
+
await db.update(authConfig).set({
|
|
7433
|
+
passwordHash: null,
|
|
7434
|
+
updatedAt: Date.now()
|
|
7435
|
+
}).where(eq(authConfig.id, 1));
|
|
7436
|
+
await deleteAllSessions();
|
|
7437
|
+
deleteCookie(context, sessionCookieName, { path: "/" });
|
|
7438
|
+
return context.json({ enabled: false });
|
|
7439
|
+
}
|
|
7440
|
+
if (newPassword.length < 8) return context.json({ error: { message: `Choose a password of at least 8 characters.` } }, 400);
|
|
7441
|
+
const hash = await hashPassword(newPassword);
|
|
7442
|
+
await db.update(authConfig).set({
|
|
7443
|
+
passwordHash: hash,
|
|
7444
|
+
updatedAt: Date.now()
|
|
7445
|
+
}).where(eq(authConfig.id, 1));
|
|
7446
|
+
await deleteAllSessions();
|
|
7447
|
+
setCookie(context, sessionCookieName, await createSession(context.req.header("user-agent") ?? null), sessionCookieOptions());
|
|
7448
|
+
return context.json({ enabled: true });
|
|
7449
|
+
});
|
|
7450
|
+
//#endregion
|
|
7451
|
+
//#region src/api/middleware/auth.ts
|
|
7452
|
+
const lastSeenThrottleMs = 60 * 1e3;
|
|
7453
|
+
let lastSweepAt = 0;
|
|
7454
|
+
const sweepIntervalMs = 300 * 1e3;
|
|
7455
|
+
async function sweepExpiredSessions(now) {
|
|
7456
|
+
if (now - lastSweepAt < sweepIntervalMs) return;
|
|
7457
|
+
lastSweepAt = now;
|
|
7458
|
+
await db.delete(sessions$1).where(lt(sessions$1.expiresAt, now));
|
|
7459
|
+
}
|
|
7460
|
+
const authMiddleware = createMiddleware(async (context, next) => {
|
|
7461
|
+
const pathname = new URL(context.req.url).pathname;
|
|
7462
|
+
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/setup")) return next();
|
|
7463
|
+
const [config] = await db.select().from(authConfig).where(eq(authConfig.id, 1)).limit(1);
|
|
7464
|
+
if (!config || config.passwordHash === null) return next();
|
|
7465
|
+
const token = getCookie(context, sessionCookieName);
|
|
7466
|
+
if (!token) return context.json({ error: { message: "Authentication required." } }, 401);
|
|
7467
|
+
const [session] = await db.select().from(sessions$1).where(eq(sessions$1.id, token)).limit(1);
|
|
7468
|
+
const now = Date.now();
|
|
7469
|
+
await sweepExpiredSessions(now);
|
|
7470
|
+
if (!session || session.expiresAt <= now) {
|
|
7471
|
+
if (session) await db.delete(sessions$1).where(eq(sessions$1.id, session.id));
|
|
7472
|
+
return context.json({ error: { message: "Your session has expired. Please sign in again." } }, 401);
|
|
7473
|
+
}
|
|
7474
|
+
if (now - session.lastSeenAt > lastSeenThrottleMs) await db.update(sessions$1).set({ lastSeenAt: now }).where(eq(sessions$1.id, session.id));
|
|
7475
|
+
return next();
|
|
7476
|
+
});
|
|
7477
|
+
//#endregion
|
|
7478
|
+
//#region src/api/middleware/profile.ts
|
|
7479
|
+
const profileCookieName = "jukebox_profile_id";
|
|
7480
|
+
const oneYearSeconds = 3600 * 24 * 365;
|
|
7481
|
+
function setProfileCookie(context, profileId) {
|
|
7482
|
+
setCookie(context, profileCookieName, String(profileId), {
|
|
7483
|
+
httpOnly: true,
|
|
7484
|
+
maxAge: oneYearSeconds,
|
|
7485
|
+
path: "/",
|
|
7486
|
+
sameSite: "Lax"
|
|
7487
|
+
});
|
|
7488
|
+
}
|
|
7489
|
+
async function readProfileFromCookie(rawCookieValue) {
|
|
7490
|
+
if (!rawCookieValue) return null;
|
|
7491
|
+
const parsedId = parseInt(rawCookieValue, 10);
|
|
7492
|
+
if (isNaN(parsedId)) return null;
|
|
7493
|
+
const [profile] = await db.select().from(profiles).where(eq(profiles.id, parsedId)).limit(1);
|
|
7494
|
+
return profile ?? null;
|
|
7495
|
+
}
|
|
7496
|
+
async function fallbackProfile() {
|
|
7497
|
+
const [latest] = await db.select().from(profiles).orderBy(desc(profiles.createdAt)).limit(1);
|
|
7498
|
+
if (latest) return latest;
|
|
7499
|
+
const now = /* @__PURE__ */ new Date();
|
|
7500
|
+
const [created] = await db.insert(profiles).values({
|
|
7501
|
+
name: "Default",
|
|
7502
|
+
emoji: "🍿",
|
|
7503
|
+
createdAt: now
|
|
7504
|
+
}).returning();
|
|
7505
|
+
if (!created) throw new Error("Failed to create default profile");
|
|
7506
|
+
return created;
|
|
7507
|
+
}
|
|
7508
|
+
const profileMiddleware = createMiddleware(async (context, next) => {
|
|
7509
|
+
const fromCookie = await readProfileFromCookie(getCookie(context, profileCookieName));
|
|
7510
|
+
const profile = fromCookie ?? await fallbackProfile();
|
|
7511
|
+
if (!fromCookie || fromCookie.id !== profile.id) setProfileCookie(context, profile.id);
|
|
7512
|
+
context.set("profileId", profile.id);
|
|
7513
|
+
await next();
|
|
7514
|
+
});
|
|
7515
|
+
//#endregion
|
|
6726
7516
|
//#region src/api/routes/episode-progress.ts
|
|
6727
7517
|
const episodeProgressRoutes = new Hono();
|
|
6728
7518
|
episodeProgressRoutes.get("/show/:showId", async (context) => {
|
|
7519
|
+
const profileId = context.get("profileId");
|
|
6729
7520
|
const showId = parseInt(context.req.param("showId"), 10);
|
|
6730
7521
|
if (isNaN(showId)) return context.json({ error: { message: "Invalid show ID" } }, 400);
|
|
6731
7522
|
const episodeIds = (await db.select({ id: episodes.id }).from(episodes).where(eq(episodes.showId, showId))).map((episode) => episode.id);
|
|
@@ -6735,7 +7526,7 @@ episodeProgressRoutes.get("/show/:showId", async (context) => {
|
|
|
6735
7526
|
currentTime: watchProgress.currentTime,
|
|
6736
7527
|
duration: watchProgress.duration,
|
|
6737
7528
|
updatedAt: watchProgress.updatedAt
|
|
6738
|
-
}).from(watchProgress).where(and(inArray(watchProgress.episodeId, episodeIds), gt(watchProgress.currentTime, 0)));
|
|
7529
|
+
}).from(watchProgress).where(and(eq(watchProgress.profileId, profileId), inArray(watchProgress.episodeId, episodeIds), gt(watchProgress.currentTime, 0)));
|
|
6739
7530
|
const progressMap = {};
|
|
6740
7531
|
for (const row of progressRows) if (row.episodeId !== null) progressMap[row.episodeId] = {
|
|
6741
7532
|
currentTime: row.currentTime,
|
|
@@ -6745,9 +7536,10 @@ episodeProgressRoutes.get("/show/:showId", async (context) => {
|
|
|
6745
7536
|
return context.json(progressMap);
|
|
6746
7537
|
});
|
|
6747
7538
|
episodeProgressRoutes.get("/:episodeId", async (context) => {
|
|
7539
|
+
const profileId = context.get("profileId");
|
|
6748
7540
|
const episodeId = parseInt(context.req.param("episodeId"), 10);
|
|
6749
7541
|
if (isNaN(episodeId)) return context.json({ error: { message: "Invalid episode ID" } }, 400);
|
|
6750
|
-
const [progress] = await db.select().from(watchProgress).where(eq(watchProgress.episodeId, episodeId)).limit(1);
|
|
7542
|
+
const [progress] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.episodeId, episodeId))).limit(1);
|
|
6751
7543
|
if (!progress) return context.json({
|
|
6752
7544
|
currentTime: 0,
|
|
6753
7545
|
duration: null
|
|
@@ -6758,18 +7550,20 @@ episodeProgressRoutes.get("/:episodeId", async (context) => {
|
|
|
6758
7550
|
});
|
|
6759
7551
|
});
|
|
6760
7552
|
episodeProgressRoutes.put("/:episodeId", async (context) => {
|
|
7553
|
+
const profileId = context.get("profileId");
|
|
6761
7554
|
const episodeId = parseInt(context.req.param("episodeId"), 10);
|
|
6762
7555
|
if (isNaN(episodeId)) return context.json({ error: { message: "Invalid episode ID" } }, 400);
|
|
6763
7556
|
const body = await context.req.json();
|
|
6764
7557
|
if (typeof body.currentTime !== "number") return context.json({ error: { message: "currentTime is required" } }, 400);
|
|
6765
|
-
const [existing] = await db.select().from(watchProgress).where(eq(watchProgress.episodeId, episodeId)).limit(1);
|
|
7558
|
+
const [existing] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.episodeId, episodeId))).limit(1);
|
|
6766
7559
|
const now = /* @__PURE__ */ new Date();
|
|
6767
7560
|
if (existing) await db.update(watchProgress).set({
|
|
6768
7561
|
currentTime: Math.floor(body.currentTime),
|
|
6769
7562
|
duration: body.duration ? Math.floor(body.duration) : existing.duration,
|
|
6770
7563
|
updatedAt: now
|
|
6771
|
-
}).where(eq(watchProgress.
|
|
7564
|
+
}).where(eq(watchProgress.id, existing.id));
|
|
6772
7565
|
else await db.insert(watchProgress).values({
|
|
7566
|
+
profileId,
|
|
6773
7567
|
episodeId,
|
|
6774
7568
|
currentTime: Math.floor(body.currentTime),
|
|
6775
7569
|
duration: body.duration ? Math.floor(body.duration) : null,
|
|
@@ -6778,17 +7572,6 @@ episodeProgressRoutes.put("/:episodeId", async (context) => {
|
|
|
6778
7572
|
return context.json({ success: true });
|
|
6779
7573
|
});
|
|
6780
7574
|
//#endregion
|
|
6781
|
-
//#region node_modules/tiny-invariant/dist/esm/tiny-invariant.js
|
|
6782
|
-
var isProduction = process.env.NODE_ENV === "production";
|
|
6783
|
-
var prefix = "Invariant failed";
|
|
6784
|
-
function invariant(condition, message) {
|
|
6785
|
-
if (condition) return;
|
|
6786
|
-
if (isProduction) throw new Error(prefix);
|
|
6787
|
-
var provided = typeof message === "function" ? message() : message;
|
|
6788
|
-
var value = provided ? "".concat(prefix, ": ").concat(provided) : prefix;
|
|
6789
|
-
throw new Error(value);
|
|
6790
|
-
}
|
|
6791
|
-
//#endregion
|
|
6792
7575
|
//#region src/api/routes/episode-stream.ts
|
|
6793
7576
|
const mimeTypes$1 = {
|
|
6794
7577
|
".avi": "video/x-msvideo",
|
|
@@ -6853,38 +7636,352 @@ helloRoutes.put("/", (c) => c.json({
|
|
|
6853
7636
|
}));
|
|
6854
7637
|
helloRoutes.get("/:name", (c) => c.json({ message: `Hello, ${c.req.param("name")}!` }));
|
|
6855
7638
|
//#endregion
|
|
6856
|
-
//#region src/
|
|
6857
|
-
const
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
}
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
6868
|
-
|
|
7639
|
+
//#region src/services/filename-parser.ts
|
|
7640
|
+
const subtitleExtensions$1 = {
|
|
7641
|
+
".ass": "ass",
|
|
7642
|
+
".srt": "srt",
|
|
7643
|
+
".vtt": "vtt"
|
|
7644
|
+
};
|
|
7645
|
+
const threeLetterToTwoLetter = {
|
|
7646
|
+
ara: "ar",
|
|
7647
|
+
ces: "cs",
|
|
7648
|
+
cze: "cs",
|
|
7649
|
+
dan: "da",
|
|
7650
|
+
deu: "de",
|
|
7651
|
+
dut: "nl",
|
|
7652
|
+
ell: "el",
|
|
7653
|
+
eng: "en",
|
|
7654
|
+
fin: "fi",
|
|
7655
|
+
fra: "fr",
|
|
7656
|
+
fre: "fr",
|
|
7657
|
+
ger: "de",
|
|
7658
|
+
gre: "el",
|
|
7659
|
+
heb: "he",
|
|
7660
|
+
hin: "hi",
|
|
7661
|
+
hun: "hu",
|
|
7662
|
+
ind: "id",
|
|
7663
|
+
ita: "it",
|
|
7664
|
+
jpn: "ja",
|
|
7665
|
+
kor: "ko",
|
|
7666
|
+
nld: "nl",
|
|
7667
|
+
nor: "no",
|
|
7668
|
+
pol: "pl",
|
|
7669
|
+
por: "pt",
|
|
7670
|
+
ron: "ro",
|
|
7671
|
+
rum: "ro",
|
|
7672
|
+
rus: "ru",
|
|
7673
|
+
spa: "es",
|
|
7674
|
+
swe: "sv",
|
|
7675
|
+
tha: "th",
|
|
7676
|
+
tur: "tr",
|
|
7677
|
+
ukr: "uk",
|
|
7678
|
+
vie: "vi",
|
|
7679
|
+
zho: "zh",
|
|
7680
|
+
chi: "zh"
|
|
7681
|
+
};
|
|
7682
|
+
const fullNameToCode = {
|
|
7683
|
+
arabic: "ar",
|
|
7684
|
+
chinese: "zh",
|
|
7685
|
+
czech: "cs",
|
|
7686
|
+
danish: "da",
|
|
7687
|
+
dutch: "nl",
|
|
7688
|
+
english: "en",
|
|
7689
|
+
finnish: "fi",
|
|
7690
|
+
french: "fr",
|
|
7691
|
+
german: "de",
|
|
7692
|
+
greek: "el",
|
|
7693
|
+
hebrew: "he",
|
|
7694
|
+
hindi: "hi",
|
|
7695
|
+
hungarian: "hu",
|
|
7696
|
+
indonesian: "id",
|
|
7697
|
+
italian: "it",
|
|
7698
|
+
japanese: "ja",
|
|
7699
|
+
korean: "ko",
|
|
7700
|
+
norwegian: "no",
|
|
7701
|
+
polish: "pl",
|
|
7702
|
+
portuguese: "pt",
|
|
7703
|
+
romanian: "ro",
|
|
7704
|
+
russian: "ru",
|
|
7705
|
+
spanish: "es",
|
|
7706
|
+
swedish: "sv",
|
|
7707
|
+
thai: "th",
|
|
7708
|
+
turkish: "tr",
|
|
7709
|
+
ukrainian: "uk",
|
|
7710
|
+
vietnamese: "vi"
|
|
7711
|
+
};
|
|
7712
|
+
const subtitleModifiers = new Set([
|
|
7713
|
+
"cc",
|
|
7714
|
+
"forced",
|
|
7715
|
+
"hoh",
|
|
7716
|
+
"sdh"
|
|
7717
|
+
]);
|
|
7718
|
+
/**
|
|
7719
|
+
* Parse a movie filename to extract title and year.
|
|
7720
|
+
* The year marks the boundary between the title and technical info.
|
|
7721
|
+
*
|
|
7722
|
+
* Examples:
|
|
7723
|
+
* - Jurassic.Park.1993.720p.BrRip.264.YIFY.mp4 -> { title: "Jurassic Park", year: 1993 }
|
|
7724
|
+
* - The.Social.Network.(2010).1080p.BrRip.x264.mp4 -> { title: "The Social Network", year: 2010 }
|
|
7725
|
+
*/
|
|
7726
|
+
function parseFilename(fileName) {
|
|
7727
|
+
const name = fileName.replace(/\.[^.]+$/, "");
|
|
7728
|
+
const bracketYearMatch = name.match(/[([]((?:19|20)\d{2})[)\]]/);
|
|
7729
|
+
if (bracketYearMatch?.[1]) {
|
|
7730
|
+
const year = parseInt(bracketYearMatch[1], 10);
|
|
7731
|
+
const yearIndex = bracketYearMatch.index ?? 0;
|
|
7732
|
+
let title = name.substring(0, yearIndex);
|
|
7733
|
+
title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
|
|
7734
|
+
title = title.replace(/[-–—]+$/, "").trim();
|
|
7735
|
+
return {
|
|
7736
|
+
title,
|
|
7737
|
+
year
|
|
7738
|
+
};
|
|
7739
|
+
}
|
|
7740
|
+
const yearPattern = /[.\s]((?:19|20)\d{2})[.\s]/g;
|
|
7741
|
+
let match;
|
|
7742
|
+
let bestMatch = null;
|
|
7743
|
+
while ((match = yearPattern.exec(name)) !== null) {
|
|
7744
|
+
const yearStr = match[1];
|
|
7745
|
+
if (!yearStr) continue;
|
|
7746
|
+
const year = parseInt(yearStr, 10);
|
|
7747
|
+
const afterYear = name.substring(match.index + match[0].length);
|
|
7748
|
+
const looksLikeTechInfo = /^(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|HDTV|DVDRip|x264|x265|H\.?264)/i.test(afterYear);
|
|
7749
|
+
if (looksLikeTechInfo || !bestMatch) {
|
|
7750
|
+
bestMatch = {
|
|
7751
|
+
year,
|
|
7752
|
+
index: match.index
|
|
7753
|
+
};
|
|
7754
|
+
if (looksLikeTechInfo) break;
|
|
7755
|
+
}
|
|
7756
|
+
}
|
|
7757
|
+
if (bestMatch) {
|
|
7758
|
+
let title = name.substring(0, bestMatch.index);
|
|
7759
|
+
title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
|
|
7760
|
+
title = title.replace(/[-–—]+$/, "").trim();
|
|
7761
|
+
return {
|
|
7762
|
+
title,
|
|
7763
|
+
year: bestMatch.year
|
|
7764
|
+
};
|
|
7765
|
+
}
|
|
7766
|
+
return {
|
|
7767
|
+
title: name.replace(/[._]/g, " ").replace(/\s+/g, " ").trim(),
|
|
7768
|
+
year: null
|
|
7769
|
+
};
|
|
7770
|
+
}
|
|
7771
|
+
/**
|
|
7772
|
+
* Extract year from a filename if present
|
|
7773
|
+
*/
|
|
7774
|
+
function extractYear(fileName) {
|
|
7775
|
+
return parseFilename(fileName).year;
|
|
7776
|
+
}
|
|
7777
|
+
/**
|
|
7778
|
+
* Clean a movie filename to extract a readable title
|
|
7779
|
+
*/
|
|
7780
|
+
function cleanTitle(fileName) {
|
|
7781
|
+
return parseFilename(fileName).title;
|
|
7782
|
+
}
|
|
7783
|
+
function normalizeLanguageToken(rawToken) {
|
|
7784
|
+
const token = rawToken.toLowerCase();
|
|
7785
|
+
if (subtitleModifiers.has(token)) return null;
|
|
7786
|
+
if (fullNameToCode[token]) return fullNameToCode[token];
|
|
7787
|
+
if (threeLetterToTwoLetter[token]) return threeLetterToTwoLetter[token];
|
|
7788
|
+
if (/^[a-z]{2}$/.test(token)) return token;
|
|
7789
|
+
if (/^[a-z]{3}$/.test(token)) return token;
|
|
7790
|
+
return null;
|
|
7791
|
+
}
|
|
7792
|
+
/**
|
|
7793
|
+
* Parse a sidecar subtitle filename. Returns the base name (the video filename
|
|
7794
|
+
* without the language suffix or extension), the detected language as a
|
|
7795
|
+
* lowercase code (ISO-639-1 when known, otherwise the raw token, or 'und' when
|
|
7796
|
+
* the filename has no language hint), and the subtitle format.
|
|
7797
|
+
*
|
|
7798
|
+
* Returns null when the file isn't a recognized subtitle format.
|
|
7799
|
+
*
|
|
7800
|
+
* Supported patterns:
|
|
7801
|
+
* - Movie.srt → language 'und'
|
|
7802
|
+
* - Movie.en.srt / Movie.eng.srt → 'en'
|
|
7803
|
+
* - Movie.English.srt → 'en'
|
|
7804
|
+
* - Movie.en.forced.srt → 'en' (forced/sdh/cc/hoh modifiers ignored)
|
|
7805
|
+
* - Show.S01E02.en.srt → baseName 'Show.S01E02', language 'en'
|
|
7806
|
+
*/
|
|
7807
|
+
function parseSubtitleFilename(fileName) {
|
|
7808
|
+
const lastDot = fileName.lastIndexOf(".");
|
|
7809
|
+
if (lastDot < 0) return null;
|
|
7810
|
+
const format = subtitleExtensions$1[fileName.substring(lastDot).toLowerCase()];
|
|
7811
|
+
if (!format) return null;
|
|
7812
|
+
const tokens = fileName.substring(0, lastDot).split(".");
|
|
7813
|
+
let language = "und";
|
|
7814
|
+
let baseEndIndex = tokens.length;
|
|
7815
|
+
for (let cursor = tokens.length - 1; cursor >= 1; cursor--) {
|
|
7816
|
+
const token = tokens[cursor];
|
|
7817
|
+
if (!token) continue;
|
|
7818
|
+
const lowered = token.toLowerCase();
|
|
7819
|
+
if (subtitleModifiers.has(lowered)) {
|
|
7820
|
+
baseEndIndex = cursor;
|
|
7821
|
+
continue;
|
|
7822
|
+
}
|
|
7823
|
+
const normalized = normalizeLanguageToken(token);
|
|
7824
|
+
if (normalized) {
|
|
7825
|
+
language = normalized;
|
|
7826
|
+
baseEndIndex = cursor;
|
|
7827
|
+
}
|
|
7828
|
+
break;
|
|
7829
|
+
}
|
|
7830
|
+
return {
|
|
7831
|
+
baseName: tokens.slice(0, baseEndIndex).join("."),
|
|
7832
|
+
format,
|
|
7833
|
+
language
|
|
7834
|
+
};
|
|
7835
|
+
}
|
|
6869
7836
|
//#endregion
|
|
6870
|
-
//#region src/
|
|
6871
|
-
const
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
7837
|
+
//#region src/services/media-extensions.ts
|
|
7838
|
+
const videoExtensions$1 = new Set([
|
|
7839
|
+
".mp4",
|
|
7840
|
+
".mkv",
|
|
7841
|
+
".avi",
|
|
7842
|
+
".mov",
|
|
7843
|
+
".wmv",
|
|
7844
|
+
".m4v",
|
|
7845
|
+
".webm",
|
|
7846
|
+
".flv",
|
|
7847
|
+
".mpeg",
|
|
7848
|
+
".mpg"
|
|
7849
|
+
]);
|
|
7850
|
+
const subtitleExtensions = new Set([
|
|
7851
|
+
".ass",
|
|
7852
|
+
".srt",
|
|
7853
|
+
".vtt"
|
|
7854
|
+
]);
|
|
7855
|
+
//#endregion
|
|
7856
|
+
//#region src/services/subtitles.ts
|
|
7857
|
+
const languageNames = {
|
|
7858
|
+
ar: "Arabic",
|
|
7859
|
+
cs: "Czech",
|
|
7860
|
+
da: "Danish",
|
|
7861
|
+
de: "German",
|
|
7862
|
+
el: "Greek",
|
|
7863
|
+
en: "English",
|
|
7864
|
+
es: "Spanish",
|
|
7865
|
+
fi: "Finnish",
|
|
7866
|
+
fr: "French",
|
|
7867
|
+
he: "Hebrew",
|
|
7868
|
+
hi: "Hindi",
|
|
7869
|
+
hu: "Hungarian",
|
|
7870
|
+
id: "Indonesian",
|
|
7871
|
+
it: "Italian",
|
|
7872
|
+
ja: "Japanese",
|
|
7873
|
+
ko: "Korean",
|
|
7874
|
+
nl: "Dutch",
|
|
7875
|
+
no: "Norwegian",
|
|
7876
|
+
pl: "Polish",
|
|
7877
|
+
pt: "Portuguese",
|
|
7878
|
+
ro: "Romanian",
|
|
7879
|
+
ru: "Russian",
|
|
7880
|
+
sv: "Swedish",
|
|
7881
|
+
th: "Thai",
|
|
7882
|
+
tr: "Turkish",
|
|
7883
|
+
uk: "Ukrainian",
|
|
7884
|
+
vi: "Vietnamese",
|
|
7885
|
+
zh: "Chinese"
|
|
7886
|
+
};
|
|
7887
|
+
/**
|
|
7888
|
+
* Map a language code to a human-readable label. Returns "Unknown" for the
|
|
7889
|
+
* undetermined sentinel and the raw code for languages not in our table.
|
|
7890
|
+
*/
|
|
7891
|
+
function languageDisplayName(code) {
|
|
7892
|
+
if (code === "und") return "Unknown";
|
|
7893
|
+
return languageNames[code] ?? code;
|
|
7894
|
+
}
|
|
7895
|
+
function stripVideoExtension(fileName) {
|
|
7896
|
+
const extension = extname(fileName);
|
|
7897
|
+
if (!extension) return fileName;
|
|
7898
|
+
return fileName.substring(0, fileName.length - extension.length);
|
|
7899
|
+
}
|
|
7900
|
+
/**
|
|
7901
|
+
* Match sidecar subtitles to a video file. Siblings must already be filtered
|
|
7902
|
+
* to subtitle entries (see `readSubtitleSiblings`). A subtitle matches when
|
|
7903
|
+
* its base name (after stripping language suffix and modifiers) equals the
|
|
7904
|
+
* video filename without extension.
|
|
7905
|
+
*
|
|
7906
|
+
* Results are returned in stable order (by file path) so callers can compare
|
|
7907
|
+
* against existing rows without spurious diffs.
|
|
7908
|
+
*/
|
|
7909
|
+
function discoverSubtitlesForVideo(videoFilePath, siblingSubtitleFiles) {
|
|
7910
|
+
const directory = dirname(videoFilePath);
|
|
7911
|
+
const videoStem = stripVideoExtension(basename(videoFilePath));
|
|
7912
|
+
const matches = [];
|
|
7913
|
+
for (const sibling of siblingSubtitleFiles) {
|
|
7914
|
+
const parsed = parseSubtitleFilename(sibling);
|
|
7915
|
+
if (!parsed) continue;
|
|
7916
|
+
if (parsed.baseName !== videoStem) continue;
|
|
7917
|
+
matches.push({
|
|
7918
|
+
filePath: join(directory, sibling),
|
|
7919
|
+
format: parsed.format,
|
|
7920
|
+
language: parsed.language
|
|
7921
|
+
});
|
|
7922
|
+
}
|
|
7923
|
+
matches.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
7924
|
+
return matches;
|
|
7925
|
+
}
|
|
7926
|
+
/**
|
|
7927
|
+
* Convert SubRip (.srt) text into WebVTT (.vtt) text. Browsers don't natively
|
|
7928
|
+
* support .srt, so the streaming endpoint converts on the fly.
|
|
7929
|
+
*
|
|
7930
|
+
* Handles UTF-8 BOM, CRLF line endings, and the comma-vs-period decimal in
|
|
7931
|
+
* timestamps. Cue identifiers are preserved (they're optional in WebVTT but
|
|
7932
|
+
* harmless when present).
|
|
7933
|
+
*/
|
|
7934
|
+
function convertSrtToVtt(srt) {
|
|
7935
|
+
let body = srt;
|
|
7936
|
+
if (body.charCodeAt(0) === 65279) body = body.substring(1);
|
|
7937
|
+
body = body.replace(/\r\n?/g, "\n");
|
|
7938
|
+
body = body.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2 --> $3.$4");
|
|
7939
|
+
return `WEBVTT\n\n${body.trimStart()}`;
|
|
7940
|
+
}
|
|
7941
|
+
//#endregion
|
|
7942
|
+
//#region src/api/routes/library.ts
|
|
7943
|
+
const libraryRoutes = new Hono();
|
|
7944
|
+
libraryRoutes.get("/movies", async (c) => {
|
|
7945
|
+
const movies$3 = await db.select().from(movies).orderBy(movies.title);
|
|
7946
|
+
return c.json(movies$3);
|
|
7947
|
+
});
|
|
7948
|
+
libraryRoutes.get("/movies/:id", async (c) => {
|
|
7949
|
+
const id = parseInt(c.req.param("id"), 10);
|
|
7950
|
+
if (isNaN(id)) return c.json({ error: { message: "Invalid movie ID" } }, 400);
|
|
7951
|
+
const [movie] = await db.select().from(movies).where(eq(movies.id, id)).limit(1);
|
|
7952
|
+
if (!movie) return c.json({ error: { message: "Movie not found" } }, 404);
|
|
7953
|
+
const subtitles$2 = (await db.select().from(subtitles).where(eq(subtitles.movieId, id))).map((row) => ({
|
|
7954
|
+
id: row.id,
|
|
7955
|
+
displayLanguage: languageDisplayName(row.language),
|
|
7956
|
+
format: row.format,
|
|
7957
|
+
isSupported: row.format !== "ass",
|
|
7958
|
+
language: row.language
|
|
7959
|
+
}));
|
|
7960
|
+
return c.json({
|
|
7961
|
+
...movie,
|
|
7962
|
+
subtitles: subtitles$2
|
|
7963
|
+
});
|
|
7964
|
+
});
|
|
7965
|
+
//#endregion
|
|
7966
|
+
//#region src/api/routes/progress.ts
|
|
7967
|
+
const progressRoutes = new Hono();
|
|
7968
|
+
progressRoutes.get("/continue-watching", async (context) => {
|
|
7969
|
+
const profileId = context.get("profileId");
|
|
7970
|
+
const inProgressFilter = and(eq(watchProgress.profileId, profileId), gt(watchProgress.currentTime, 0), sql`(${watchProgress.duration} IS NULL OR ${watchProgress.currentTime} < ${watchProgress.duration} * 0.9)`);
|
|
7971
|
+
const movieResults = await db.select({
|
|
7972
|
+
currentTime: watchProgress.currentTime,
|
|
7973
|
+
duration: watchProgress.duration,
|
|
7974
|
+
movie: movies,
|
|
7975
|
+
updatedAt: watchProgress.updatedAt
|
|
7976
|
+
}).from(watchProgress).innerJoin(movies, eq(watchProgress.movieId, movies.id)).where(inProgressFilter);
|
|
7977
|
+
const episodeResults = await db.select({
|
|
7978
|
+
currentTime: watchProgress.currentTime,
|
|
7979
|
+
duration: watchProgress.duration,
|
|
7980
|
+
episode: episodes,
|
|
7981
|
+
show: shows,
|
|
7982
|
+
updatedAt: watchProgress.updatedAt
|
|
7983
|
+
}).from(watchProgress).innerJoin(episodes, eq(watchProgress.episodeId, episodes.id)).innerJoin(shows, eq(episodes.showId, shows.id)).where(inProgressFilter);
|
|
7984
|
+
const movies$2 = movieResults.map((result) => ({
|
|
6888
7985
|
...result,
|
|
6889
7986
|
type: "movie"
|
|
6890
7987
|
}));
|
|
@@ -6892,13 +7989,14 @@ progressRoutes.get("/continue-watching", async (context) => {
|
|
|
6892
7989
|
...result,
|
|
6893
7990
|
type: "episode"
|
|
6894
7991
|
}));
|
|
6895
|
-
const combined = [...movies$
|
|
7992
|
+
const combined = [...movies$2, ...episodes$2].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).slice(0, 20);
|
|
6896
7993
|
return context.json(combined);
|
|
6897
7994
|
});
|
|
6898
7995
|
progressRoutes.get("/:movieId", async (c) => {
|
|
7996
|
+
const profileId = c.get("profileId");
|
|
6899
7997
|
const movieId = parseInt(c.req.param("movieId"), 10);
|
|
6900
7998
|
if (isNaN(movieId)) return c.json({ error: { message: "Invalid movie ID" } }, 400);
|
|
6901
|
-
const [progress] = await db.select().from(watchProgress).where(eq(watchProgress.movieId, movieId)).limit(1);
|
|
7999
|
+
const [progress] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.movieId, movieId))).limit(1);
|
|
6902
8000
|
if (!progress) return c.json({
|
|
6903
8001
|
currentTime: 0,
|
|
6904
8002
|
duration: null
|
|
@@ -6909,18 +8007,20 @@ progressRoutes.get("/:movieId", async (c) => {
|
|
|
6909
8007
|
});
|
|
6910
8008
|
});
|
|
6911
8009
|
progressRoutes.put("/:movieId", async (c) => {
|
|
8010
|
+
const profileId = c.get("profileId");
|
|
6912
8011
|
const movieId = parseInt(c.req.param("movieId"), 10);
|
|
6913
8012
|
if (isNaN(movieId)) return c.json({ error: { message: "Invalid movie ID" } }, 400);
|
|
6914
8013
|
const body = await c.req.json();
|
|
6915
8014
|
if (typeof body.currentTime !== "number") return c.json({ error: { message: "currentTime is required" } }, 400);
|
|
6916
|
-
const [existing] = await db.select().from(watchProgress).where(eq(watchProgress.movieId, movieId)).limit(1);
|
|
8015
|
+
const [existing] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.movieId, movieId))).limit(1);
|
|
6917
8016
|
const now = /* @__PURE__ */ new Date();
|
|
6918
8017
|
if (existing) await db.update(watchProgress).set({
|
|
6919
8018
|
currentTime: Math.floor(body.currentTime),
|
|
6920
8019
|
duration: body.duration ? Math.floor(body.duration) : existing.duration,
|
|
6921
8020
|
updatedAt: now
|
|
6922
|
-
}).where(eq(watchProgress.
|
|
8021
|
+
}).where(eq(watchProgress.id, existing.id));
|
|
6923
8022
|
else await db.insert(watchProgress).values({
|
|
8023
|
+
profileId,
|
|
6924
8024
|
movieId,
|
|
6925
8025
|
currentTime: Math.floor(body.currentTime),
|
|
6926
8026
|
duration: body.duration ? Math.floor(body.duration) : null,
|
|
@@ -7060,15 +8160,551 @@ var streamSSE = (c, cb, onError) => {
|
|
|
7060
8160
|
return c.newResponse(stream.responseReadable);
|
|
7061
8161
|
};
|
|
7062
8162
|
//#endregion
|
|
8163
|
+
//#region node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
8164
|
+
const ANSI_BACKGROUND_OFFSET = 10;
|
|
8165
|
+
const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
|
|
8166
|
+
const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
|
|
8167
|
+
const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
|
|
8168
|
+
const styles$1 = {
|
|
8169
|
+
modifier: {
|
|
8170
|
+
reset: [0, 0],
|
|
8171
|
+
bold: [1, 22],
|
|
8172
|
+
dim: [2, 22],
|
|
8173
|
+
italic: [3, 23],
|
|
8174
|
+
underline: [4, 24],
|
|
8175
|
+
overline: [53, 55],
|
|
8176
|
+
inverse: [7, 27],
|
|
8177
|
+
hidden: [8, 28],
|
|
8178
|
+
strikethrough: [9, 29]
|
|
8179
|
+
},
|
|
8180
|
+
color: {
|
|
8181
|
+
black: [30, 39],
|
|
8182
|
+
red: [31, 39],
|
|
8183
|
+
green: [32, 39],
|
|
8184
|
+
yellow: [33, 39],
|
|
8185
|
+
blue: [34, 39],
|
|
8186
|
+
magenta: [35, 39],
|
|
8187
|
+
cyan: [36, 39],
|
|
8188
|
+
white: [37, 39],
|
|
8189
|
+
blackBright: [90, 39],
|
|
8190
|
+
gray: [90, 39],
|
|
8191
|
+
grey: [90, 39],
|
|
8192
|
+
redBright: [91, 39],
|
|
8193
|
+
greenBright: [92, 39],
|
|
8194
|
+
yellowBright: [93, 39],
|
|
8195
|
+
blueBright: [94, 39],
|
|
8196
|
+
magentaBright: [95, 39],
|
|
8197
|
+
cyanBright: [96, 39],
|
|
8198
|
+
whiteBright: [97, 39]
|
|
8199
|
+
},
|
|
8200
|
+
bgColor: {
|
|
8201
|
+
bgBlack: [40, 49],
|
|
8202
|
+
bgRed: [41, 49],
|
|
8203
|
+
bgGreen: [42, 49],
|
|
8204
|
+
bgYellow: [43, 49],
|
|
8205
|
+
bgBlue: [44, 49],
|
|
8206
|
+
bgMagenta: [45, 49],
|
|
8207
|
+
bgCyan: [46, 49],
|
|
8208
|
+
bgWhite: [47, 49],
|
|
8209
|
+
bgBlackBright: [100, 49],
|
|
8210
|
+
bgGray: [100, 49],
|
|
8211
|
+
bgGrey: [100, 49],
|
|
8212
|
+
bgRedBright: [101, 49],
|
|
8213
|
+
bgGreenBright: [102, 49],
|
|
8214
|
+
bgYellowBright: [103, 49],
|
|
8215
|
+
bgBlueBright: [104, 49],
|
|
8216
|
+
bgMagentaBright: [105, 49],
|
|
8217
|
+
bgCyanBright: [106, 49],
|
|
8218
|
+
bgWhiteBright: [107, 49]
|
|
8219
|
+
}
|
|
8220
|
+
};
|
|
8221
|
+
Object.keys(styles$1.modifier);
|
|
8222
|
+
const foregroundColorNames = Object.keys(styles$1.color);
|
|
8223
|
+
const backgroundColorNames = Object.keys(styles$1.bgColor);
|
|
8224
|
+
[...foregroundColorNames, ...backgroundColorNames];
|
|
8225
|
+
function assembleStyles() {
|
|
8226
|
+
const codes = /* @__PURE__ */ new Map();
|
|
8227
|
+
for (const [groupName, group] of Object.entries(styles$1)) {
|
|
8228
|
+
for (const [styleName, style] of Object.entries(group)) {
|
|
8229
|
+
styles$1[styleName] = {
|
|
8230
|
+
open: `\u001B[${style[0]}m`,
|
|
8231
|
+
close: `\u001B[${style[1]}m`
|
|
8232
|
+
};
|
|
8233
|
+
group[styleName] = styles$1[styleName];
|
|
8234
|
+
codes.set(style[0], style[1]);
|
|
8235
|
+
}
|
|
8236
|
+
Object.defineProperty(styles$1, groupName, {
|
|
8237
|
+
value: group,
|
|
8238
|
+
enumerable: false
|
|
8239
|
+
});
|
|
8240
|
+
}
|
|
8241
|
+
Object.defineProperty(styles$1, "codes", {
|
|
8242
|
+
value: codes,
|
|
8243
|
+
enumerable: false
|
|
8244
|
+
});
|
|
8245
|
+
styles$1.color.close = "\x1B[39m";
|
|
8246
|
+
styles$1.bgColor.close = "\x1B[49m";
|
|
8247
|
+
styles$1.color.ansi = wrapAnsi16();
|
|
8248
|
+
styles$1.color.ansi256 = wrapAnsi256();
|
|
8249
|
+
styles$1.color.ansi16m = wrapAnsi16m();
|
|
8250
|
+
styles$1.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET);
|
|
8251
|
+
styles$1.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET);
|
|
8252
|
+
styles$1.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
|
|
8253
|
+
Object.defineProperties(styles$1, {
|
|
8254
|
+
rgbToAnsi256: {
|
|
8255
|
+
value(red, green, blue) {
|
|
8256
|
+
if (red === green && green === blue) {
|
|
8257
|
+
if (red < 8) return 16;
|
|
8258
|
+
if (red > 248) return 231;
|
|
8259
|
+
return Math.round((red - 8) / 247 * 24) + 232;
|
|
8260
|
+
}
|
|
8261
|
+
return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5);
|
|
8262
|
+
},
|
|
8263
|
+
enumerable: false
|
|
8264
|
+
},
|
|
8265
|
+
hexToRgb: {
|
|
8266
|
+
value(hex) {
|
|
8267
|
+
const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
|
|
8268
|
+
if (!matches) return [
|
|
8269
|
+
0,
|
|
8270
|
+
0,
|
|
8271
|
+
0
|
|
8272
|
+
];
|
|
8273
|
+
let [colorString] = matches;
|
|
8274
|
+
if (colorString.length === 3) colorString = [...colorString].map((character) => character + character).join("");
|
|
8275
|
+
const integer = Number.parseInt(colorString, 16);
|
|
8276
|
+
return [
|
|
8277
|
+
integer >> 16 & 255,
|
|
8278
|
+
integer >> 8 & 255,
|
|
8279
|
+
integer & 255
|
|
8280
|
+
];
|
|
8281
|
+
},
|
|
8282
|
+
enumerable: false
|
|
8283
|
+
},
|
|
8284
|
+
hexToAnsi256: {
|
|
8285
|
+
value: (hex) => styles$1.rgbToAnsi256(...styles$1.hexToRgb(hex)),
|
|
8286
|
+
enumerable: false
|
|
8287
|
+
},
|
|
8288
|
+
ansi256ToAnsi: {
|
|
8289
|
+
value(code) {
|
|
8290
|
+
if (code < 8) return 30 + code;
|
|
8291
|
+
if (code < 16) return 90 + (code - 8);
|
|
8292
|
+
let red;
|
|
8293
|
+
let green;
|
|
8294
|
+
let blue;
|
|
8295
|
+
if (code >= 232) {
|
|
8296
|
+
red = ((code - 232) * 10 + 8) / 255;
|
|
8297
|
+
green = red;
|
|
8298
|
+
blue = red;
|
|
8299
|
+
} else {
|
|
8300
|
+
code -= 16;
|
|
8301
|
+
const remainder = code % 36;
|
|
8302
|
+
red = Math.floor(code / 36) / 5;
|
|
8303
|
+
green = Math.floor(remainder / 6) / 5;
|
|
8304
|
+
blue = remainder % 6 / 5;
|
|
8305
|
+
}
|
|
8306
|
+
const value = Math.max(red, green, blue) * 2;
|
|
8307
|
+
if (value === 0) return 30;
|
|
8308
|
+
let result = 30 + (Math.round(blue) << 2 | Math.round(green) << 1 | Math.round(red));
|
|
8309
|
+
if (value === 2) result += 60;
|
|
8310
|
+
return result;
|
|
8311
|
+
},
|
|
8312
|
+
enumerable: false
|
|
8313
|
+
},
|
|
8314
|
+
rgbToAnsi: {
|
|
8315
|
+
value: (red, green, blue) => styles$1.ansi256ToAnsi(styles$1.rgbToAnsi256(red, green, blue)),
|
|
8316
|
+
enumerable: false
|
|
8317
|
+
},
|
|
8318
|
+
hexToAnsi: {
|
|
8319
|
+
value: (hex) => styles$1.ansi256ToAnsi(styles$1.hexToAnsi256(hex)),
|
|
8320
|
+
enumerable: false
|
|
8321
|
+
}
|
|
8322
|
+
});
|
|
8323
|
+
return styles$1;
|
|
8324
|
+
}
|
|
8325
|
+
const ansiStyles = assembleStyles();
|
|
8326
|
+
//#endregion
|
|
8327
|
+
//#region node_modules/chalk/source/vendor/supports-color/index.js
|
|
8328
|
+
function hasFlag(flag, argv = globalThis.Deno ? globalThis.Deno.args : process$1.argv) {
|
|
8329
|
+
const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
|
|
8330
|
+
const position = argv.indexOf(prefix + flag);
|
|
8331
|
+
const terminatorPosition = argv.indexOf("--");
|
|
8332
|
+
return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
|
|
8333
|
+
}
|
|
8334
|
+
const { env } = process$1;
|
|
8335
|
+
let flagForceColor;
|
|
8336
|
+
if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) flagForceColor = 0;
|
|
8337
|
+
else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) flagForceColor = 1;
|
|
8338
|
+
function envForceColor() {
|
|
8339
|
+
if ("FORCE_COLOR" in env) {
|
|
8340
|
+
if (env.FORCE_COLOR === "true") return 1;
|
|
8341
|
+
if (env.FORCE_COLOR === "false") return 0;
|
|
8342
|
+
return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3);
|
|
8343
|
+
}
|
|
8344
|
+
}
|
|
8345
|
+
function translateLevel(level) {
|
|
8346
|
+
if (level === 0) return false;
|
|
8347
|
+
return {
|
|
8348
|
+
level,
|
|
8349
|
+
hasBasic: true,
|
|
8350
|
+
has256: level >= 2,
|
|
8351
|
+
has16m: level >= 3
|
|
8352
|
+
};
|
|
8353
|
+
}
|
|
8354
|
+
function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
|
|
8355
|
+
const noFlagForceColor = envForceColor();
|
|
8356
|
+
if (noFlagForceColor !== void 0) flagForceColor = noFlagForceColor;
|
|
8357
|
+
const forceColor = sniffFlags ? flagForceColor : noFlagForceColor;
|
|
8358
|
+
if (forceColor === 0) return 0;
|
|
8359
|
+
if (sniffFlags) {
|
|
8360
|
+
if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) return 3;
|
|
8361
|
+
if (hasFlag("color=256")) return 2;
|
|
8362
|
+
}
|
|
8363
|
+
if ("TF_BUILD" in env && "AGENT_NAME" in env) return 1;
|
|
8364
|
+
if (haveStream && !streamIsTTY && forceColor === void 0) return 0;
|
|
8365
|
+
const min = forceColor || 0;
|
|
8366
|
+
if (env.TERM === "dumb") return min;
|
|
8367
|
+
if (process$1.platform === "win32") {
|
|
8368
|
+
const osRelease = os$1.release().split(".");
|
|
8369
|
+
if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) return Number(osRelease[2]) >= 14931 ? 3 : 2;
|
|
8370
|
+
return 1;
|
|
8371
|
+
}
|
|
8372
|
+
if ("CI" in env) {
|
|
8373
|
+
if ([
|
|
8374
|
+
"GITHUB_ACTIONS",
|
|
8375
|
+
"GITEA_ACTIONS",
|
|
8376
|
+
"CIRCLECI"
|
|
8377
|
+
].some((key) => key in env)) return 3;
|
|
8378
|
+
if ([
|
|
8379
|
+
"TRAVIS",
|
|
8380
|
+
"APPVEYOR",
|
|
8381
|
+
"GITLAB_CI",
|
|
8382
|
+
"BUILDKITE",
|
|
8383
|
+
"DRONE"
|
|
8384
|
+
].some((sign) => sign in env) || env.CI_NAME === "codeship") return 1;
|
|
8385
|
+
return min;
|
|
8386
|
+
}
|
|
8387
|
+
if ("TEAMCITY_VERSION" in env) return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
|
|
8388
|
+
if (env.COLORTERM === "truecolor") return 3;
|
|
8389
|
+
if (env.TERM === "xterm-kitty") return 3;
|
|
8390
|
+
if (env.TERM === "xterm-ghostty") return 3;
|
|
8391
|
+
if (env.TERM === "wezterm") return 3;
|
|
8392
|
+
if ("TERM_PROGRAM" in env) {
|
|
8393
|
+
const version = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
|
|
8394
|
+
switch (env.TERM_PROGRAM) {
|
|
8395
|
+
case "iTerm.app": return version >= 3 ? 3 : 2;
|
|
8396
|
+
case "Apple_Terminal": return 2;
|
|
8397
|
+
}
|
|
8398
|
+
}
|
|
8399
|
+
if (/-256(color)?$/i.test(env.TERM)) return 2;
|
|
8400
|
+
if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) return 1;
|
|
8401
|
+
if ("COLORTERM" in env) return 1;
|
|
8402
|
+
return min;
|
|
8403
|
+
}
|
|
8404
|
+
function createSupportsColor(stream, options = {}) {
|
|
8405
|
+
return translateLevel(_supportsColor(stream, {
|
|
8406
|
+
streamIsTTY: stream && stream.isTTY,
|
|
8407
|
+
...options
|
|
8408
|
+
}));
|
|
8409
|
+
}
|
|
8410
|
+
const supportsColor = {
|
|
8411
|
+
stdout: createSupportsColor({ isTTY: tty.isatty(1) }),
|
|
8412
|
+
stderr: createSupportsColor({ isTTY: tty.isatty(2) })
|
|
8413
|
+
};
|
|
8414
|
+
//#endregion
|
|
8415
|
+
//#region node_modules/chalk/source/utilities.js
|
|
8416
|
+
function stringReplaceAll(string, substring, replacer) {
|
|
8417
|
+
let index = string.indexOf(substring);
|
|
8418
|
+
if (index === -1) return string;
|
|
8419
|
+
const substringLength = substring.length;
|
|
8420
|
+
let endIndex = 0;
|
|
8421
|
+
let returnValue = "";
|
|
8422
|
+
do {
|
|
8423
|
+
returnValue += string.slice(endIndex, index) + substring + replacer;
|
|
8424
|
+
endIndex = index + substringLength;
|
|
8425
|
+
index = string.indexOf(substring, endIndex);
|
|
8426
|
+
} while (index !== -1);
|
|
8427
|
+
returnValue += string.slice(endIndex);
|
|
8428
|
+
return returnValue;
|
|
8429
|
+
}
|
|
8430
|
+
function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
|
|
8431
|
+
let endIndex = 0;
|
|
8432
|
+
let returnValue = "";
|
|
8433
|
+
do {
|
|
8434
|
+
const gotCR = string[index - 1] === "\r";
|
|
8435
|
+
returnValue += string.slice(endIndex, gotCR ? index - 1 : index) + prefix + (gotCR ? "\r\n" : "\n") + postfix;
|
|
8436
|
+
endIndex = index + 1;
|
|
8437
|
+
index = string.indexOf("\n", endIndex);
|
|
8438
|
+
} while (index !== -1);
|
|
8439
|
+
returnValue += string.slice(endIndex);
|
|
8440
|
+
return returnValue;
|
|
8441
|
+
}
|
|
8442
|
+
//#endregion
|
|
8443
|
+
//#region node_modules/chalk/source/index.js
|
|
8444
|
+
const { stdout: stdoutColor, stderr: stderrColor } = supportsColor;
|
|
8445
|
+
const GENERATOR = Symbol("GENERATOR");
|
|
8446
|
+
const STYLER = Symbol("STYLER");
|
|
8447
|
+
const IS_EMPTY = Symbol("IS_EMPTY");
|
|
8448
|
+
const levelMapping = [
|
|
8449
|
+
"ansi",
|
|
8450
|
+
"ansi",
|
|
8451
|
+
"ansi256",
|
|
8452
|
+
"ansi16m"
|
|
8453
|
+
];
|
|
8454
|
+
const styles = Object.create(null);
|
|
8455
|
+
const applyOptions = (object, options = {}) => {
|
|
8456
|
+
if (options.level && !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)) throw new Error("The `level` option should be an integer from 0 to 3");
|
|
8457
|
+
const colorLevel = stdoutColor ? stdoutColor.level : 0;
|
|
8458
|
+
object.level = options.level === void 0 ? colorLevel : options.level;
|
|
8459
|
+
};
|
|
8460
|
+
const chalkFactory = (options) => {
|
|
8461
|
+
const chalk = (...strings) => strings.join(" ");
|
|
8462
|
+
applyOptions(chalk, options);
|
|
8463
|
+
Object.setPrototypeOf(chalk, createChalk.prototype);
|
|
8464
|
+
return chalk;
|
|
8465
|
+
};
|
|
8466
|
+
function createChalk(options) {
|
|
8467
|
+
return chalkFactory(options);
|
|
8468
|
+
}
|
|
8469
|
+
Object.setPrototypeOf(createChalk.prototype, Function.prototype);
|
|
8470
|
+
for (const [styleName, style] of Object.entries(ansiStyles)) styles[styleName] = { get() {
|
|
8471
|
+
const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]);
|
|
8472
|
+
Object.defineProperty(this, styleName, { value: builder });
|
|
8473
|
+
return builder;
|
|
8474
|
+
} };
|
|
8475
|
+
styles.visible = { get() {
|
|
8476
|
+
const builder = createBuilder(this, this[STYLER], true);
|
|
8477
|
+
Object.defineProperty(this, "visible", { value: builder });
|
|
8478
|
+
return builder;
|
|
8479
|
+
} };
|
|
8480
|
+
const getModelAnsi = (model, level, type, ...arguments_) => {
|
|
8481
|
+
if (model === "rgb") {
|
|
8482
|
+
if (level === "ansi16m") return ansiStyles[type].ansi16m(...arguments_);
|
|
8483
|
+
if (level === "ansi256") return ansiStyles[type].ansi256(ansiStyles.rgbToAnsi256(...arguments_));
|
|
8484
|
+
return ansiStyles[type].ansi(ansiStyles.rgbToAnsi(...arguments_));
|
|
8485
|
+
}
|
|
8486
|
+
if (model === "hex") return getModelAnsi("rgb", level, type, ...ansiStyles.hexToRgb(...arguments_));
|
|
8487
|
+
return ansiStyles[type][model](...arguments_);
|
|
8488
|
+
};
|
|
8489
|
+
for (const model of [
|
|
8490
|
+
"rgb",
|
|
8491
|
+
"hex",
|
|
8492
|
+
"ansi256"
|
|
8493
|
+
]) {
|
|
8494
|
+
styles[model] = { get() {
|
|
8495
|
+
const { level } = this;
|
|
8496
|
+
return function(...arguments_) {
|
|
8497
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], "color", ...arguments_), ansiStyles.color.close, this[STYLER]);
|
|
8498
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
8499
|
+
};
|
|
8500
|
+
} };
|
|
8501
|
+
const bgModel = "bg" + model[0].toUpperCase() + model.slice(1);
|
|
8502
|
+
styles[bgModel] = { get() {
|
|
8503
|
+
const { level } = this;
|
|
8504
|
+
return function(...arguments_) {
|
|
8505
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], "bgColor", ...arguments_), ansiStyles.bgColor.close, this[STYLER]);
|
|
8506
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
8507
|
+
};
|
|
8508
|
+
} };
|
|
8509
|
+
}
|
|
8510
|
+
const proto = Object.defineProperties(() => {}, {
|
|
8511
|
+
...styles,
|
|
8512
|
+
level: {
|
|
8513
|
+
enumerable: true,
|
|
8514
|
+
get() {
|
|
8515
|
+
return this[GENERATOR].level;
|
|
8516
|
+
},
|
|
8517
|
+
set(level) {
|
|
8518
|
+
this[GENERATOR].level = level;
|
|
8519
|
+
}
|
|
8520
|
+
}
|
|
8521
|
+
});
|
|
8522
|
+
const createStyler = (open, close, parent) => {
|
|
8523
|
+
let openAll;
|
|
8524
|
+
let closeAll;
|
|
8525
|
+
if (parent === void 0) {
|
|
8526
|
+
openAll = open;
|
|
8527
|
+
closeAll = close;
|
|
8528
|
+
} else {
|
|
8529
|
+
openAll = parent.openAll + open;
|
|
8530
|
+
closeAll = close + parent.closeAll;
|
|
8531
|
+
}
|
|
8532
|
+
return {
|
|
8533
|
+
open,
|
|
8534
|
+
close,
|
|
8535
|
+
openAll,
|
|
8536
|
+
closeAll,
|
|
8537
|
+
parent
|
|
8538
|
+
};
|
|
8539
|
+
};
|
|
8540
|
+
const createBuilder = (self, _styler, _isEmpty) => {
|
|
8541
|
+
const builder = (...arguments_) => applyStyle(builder, arguments_.length === 1 ? "" + arguments_[0] : arguments_.join(" "));
|
|
8542
|
+
Object.setPrototypeOf(builder, proto);
|
|
8543
|
+
builder[GENERATOR] = self;
|
|
8544
|
+
builder[STYLER] = _styler;
|
|
8545
|
+
builder[IS_EMPTY] = _isEmpty;
|
|
8546
|
+
return builder;
|
|
8547
|
+
};
|
|
8548
|
+
const applyStyle = (self, string) => {
|
|
8549
|
+
if (self.level <= 0 || !string) return self[IS_EMPTY] ? "" : string;
|
|
8550
|
+
let styler = self[STYLER];
|
|
8551
|
+
if (styler === void 0) return string;
|
|
8552
|
+
const { openAll, closeAll } = styler;
|
|
8553
|
+
if (string.includes("\x1B")) while (styler !== void 0) {
|
|
8554
|
+
string = stringReplaceAll(string, styler.close, styler.open);
|
|
8555
|
+
styler = styler.parent;
|
|
8556
|
+
}
|
|
8557
|
+
const lfIndex = string.indexOf("\n");
|
|
8558
|
+
if (lfIndex !== -1) string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
|
|
8559
|
+
return openAll + string + closeAll;
|
|
8560
|
+
};
|
|
8561
|
+
Object.defineProperties(createChalk.prototype, styles);
|
|
8562
|
+
const chalk = createChalk();
|
|
8563
|
+
createChalk({ level: stderrColor ? stderrColor.level : 0 });
|
|
8564
|
+
//#endregion
|
|
8565
|
+
//#region node_modules/tiny-typescript-logger/dist/index.js
|
|
8566
|
+
var logLevelToName = {
|
|
8567
|
+
[10]: "trace",
|
|
8568
|
+
[20]: "debug",
|
|
8569
|
+
[30]: "info",
|
|
8570
|
+
[40]: "warn",
|
|
8571
|
+
[50]: "error",
|
|
8572
|
+
[60]: "fatal"
|
|
8573
|
+
};
|
|
8574
|
+
var logLevelToColor = {
|
|
8575
|
+
[10]: "#ECD5E3",
|
|
8576
|
+
[20]: "#ABDEE6",
|
|
8577
|
+
[30]: "#97C1A9",
|
|
8578
|
+
[40]: "#FDFD96",
|
|
8579
|
+
[50]: "#FFB6B6",
|
|
8580
|
+
[60]: "#FF6961"
|
|
8581
|
+
};
|
|
8582
|
+
var Logger = class {
|
|
8583
|
+
debug(...items) {
|
|
8584
|
+
this.log(20, ...items);
|
|
8585
|
+
}
|
|
8586
|
+
error(...items) {
|
|
8587
|
+
this.log(50, ...items);
|
|
8588
|
+
}
|
|
8589
|
+
fatal(...items) {
|
|
8590
|
+
this.log(60, ...items);
|
|
8591
|
+
}
|
|
8592
|
+
info(...items) {
|
|
8593
|
+
this.log(30, ...items);
|
|
8594
|
+
}
|
|
8595
|
+
log(level, ...items) {
|
|
8596
|
+
if (items.length === 0) return;
|
|
8597
|
+
const message = this.isString(items[0]) ? items.shift() : "";
|
|
8598
|
+
const formattedMessage = this.formatMessage(level, message);
|
|
8599
|
+
console.log(formattedMessage, ...items);
|
|
8600
|
+
}
|
|
8601
|
+
trace(...items) {
|
|
8602
|
+
this.log(10, ...items);
|
|
8603
|
+
}
|
|
8604
|
+
warn(...items) {
|
|
8605
|
+
this.log(40, ...items);
|
|
8606
|
+
}
|
|
8607
|
+
formatMessage(level, message) {
|
|
8608
|
+
const date = (/* @__PURE__ */ new Date()).toISOString();
|
|
8609
|
+
const formattedDate = chalk.dim(date);
|
|
8610
|
+
const logLevelName = logLevelToName[level] ?? "unknown";
|
|
8611
|
+
const logLevelColor = logLevelToColor[level] ?? "#f1f5f9";
|
|
8612
|
+
return [
|
|
8613
|
+
`[${chalk.hex(logLevelColor)(logLevelName.toUpperCase())}]`,
|
|
8614
|
+
formattedDate,
|
|
8615
|
+
message
|
|
8616
|
+
].join(" ");
|
|
8617
|
+
}
|
|
8618
|
+
isString(value) {
|
|
8619
|
+
return typeof value === "string";
|
|
8620
|
+
}
|
|
8621
|
+
};
|
|
8622
|
+
var log = new Logger();
|
|
8623
|
+
//#endregion
|
|
8624
|
+
//#region src/services/settings.ts
|
|
8625
|
+
const tmdbApiKeySettingKey = "tmdbApiKey";
|
|
8626
|
+
const scanScheduleSettingKey = "scanSchedule";
|
|
8627
|
+
const scanScheduleValues = [
|
|
8628
|
+
"off",
|
|
8629
|
+
"6h",
|
|
8630
|
+
"12h",
|
|
8631
|
+
"24h"
|
|
8632
|
+
];
|
|
8633
|
+
function isScanScheduleValue(value) {
|
|
8634
|
+
return scanScheduleValues.includes(value);
|
|
8635
|
+
}
|
|
8636
|
+
/**
|
|
8637
|
+
* Read a setting value from the DB. Returns null when the key is not set.
|
|
8638
|
+
*
|
|
8639
|
+
* Accepts an optional database instance so tests and scripts can pass an
|
|
8640
|
+
* in-memory SQLite instance instead of the singleton.
|
|
8641
|
+
*/
|
|
8642
|
+
async function getSetting(key, database = db) {
|
|
8643
|
+
const [row] = await database.select().from(settings).where(eq(settings.key, key)).limit(1);
|
|
8644
|
+
return row?.value ?? null;
|
|
8645
|
+
}
|
|
8646
|
+
/**
|
|
8647
|
+
* Upsert a setting value.
|
|
8648
|
+
*
|
|
8649
|
+
* Uses a single INSERT ... ON CONFLICT so two concurrent callers don't both
|
|
8650
|
+
* read null, both INSERT, and fail with a UNIQUE constraint on the second.
|
|
8651
|
+
*/
|
|
8652
|
+
async function setSetting(key, value, database = db) {
|
|
8653
|
+
const now = Date.now();
|
|
8654
|
+
await database.insert(settings).values({
|
|
8655
|
+
key,
|
|
8656
|
+
value,
|
|
8657
|
+
updatedAt: now
|
|
8658
|
+
}).onConflictDoUpdate({
|
|
8659
|
+
target: settings.key,
|
|
8660
|
+
set: {
|
|
8661
|
+
value,
|
|
8662
|
+
updatedAt: now
|
|
8663
|
+
}
|
|
8664
|
+
});
|
|
8665
|
+
}
|
|
8666
|
+
/**
|
|
8667
|
+
* Resolve the TMDB API key using the dual-source strategy:
|
|
8668
|
+
*
|
|
8669
|
+
* 1. Prefer the value stored in the `settings` table.
|
|
8670
|
+
* 2. Fall back to `~/.jukebox/config.json` for backward compatibility on the
|
|
8671
|
+
* first boot after upgrading. Copy the JSON value into the DB once so
|
|
8672
|
+
* subsequent lookups only touch the DB.
|
|
8673
|
+
*
|
|
8674
|
+
* Returns null when neither source has a key configured.
|
|
8675
|
+
*/
|
|
8676
|
+
async function getTmdbApiKey(database = db) {
|
|
8677
|
+
const stored = await getSetting(tmdbApiKeySettingKey, database);
|
|
8678
|
+
if (stored !== null && stored.length > 0) return stored;
|
|
8679
|
+
const legacyKey = getConfig()?.tmdbApiKey ?? null;
|
|
8680
|
+
if (legacyKey !== null && legacyKey.length > 0) {
|
|
8681
|
+
await setSetting(tmdbApiKeySettingKey, legacyKey, database);
|
|
8682
|
+
return legacyKey;
|
|
8683
|
+
}
|
|
8684
|
+
return null;
|
|
8685
|
+
}
|
|
8686
|
+
/**
|
|
8687
|
+
* Persist the TMDB API key to the DB. Also mirror the value to the JSON
|
|
8688
|
+
* config file so rollbacks to an older server build keep working.
|
|
8689
|
+
*/
|
|
8690
|
+
async function setTmdbApiKey(apiKey, database = db) {
|
|
8691
|
+
await setSetting(tmdbApiKeySettingKey, apiKey, database);
|
|
8692
|
+
try {
|
|
8693
|
+
await saveConfig({ tmdbApiKey: apiKey });
|
|
8694
|
+
} catch (error) {
|
|
8695
|
+
log.warn("Couldn't mirror TMDB key to config.json — rollback safety is degraded. Check permissions on ~/.jukebox/.", error);
|
|
8696
|
+
}
|
|
8697
|
+
}
|
|
8698
|
+
//#endregion
|
|
7063
8699
|
//#region src/services/tmdb.ts
|
|
7064
8700
|
const tmdbBaseUrl = "https://api.themoviedb.org/3";
|
|
7065
|
-
function getApiKey() {
|
|
7066
|
-
const apiKey =
|
|
8701
|
+
async function getApiKey() {
|
|
8702
|
+
const apiKey = await getTmdbApiKey() ?? process.env.TMDB_API_KEY ?? null;
|
|
7067
8703
|
if (!apiKey) throw new Error("TMDB API key is not configured");
|
|
7068
8704
|
return apiKey;
|
|
7069
8705
|
}
|
|
7070
8706
|
async function searchMovie(title, year) {
|
|
7071
|
-
const apiKey = getApiKey();
|
|
8707
|
+
const apiKey = await getApiKey();
|
|
7072
8708
|
const params = new URLSearchParams({
|
|
7073
8709
|
api_key: apiKey,
|
|
7074
8710
|
query: title
|
|
@@ -7079,14 +8715,14 @@ async function searchMovie(title, year) {
|
|
|
7079
8715
|
return (await response.json()).results;
|
|
7080
8716
|
}
|
|
7081
8717
|
async function getMovieDetails(tmdbId) {
|
|
7082
|
-
const apiKey = getApiKey();
|
|
8718
|
+
const apiKey = await getApiKey();
|
|
7083
8719
|
const params = new URLSearchParams({ api_key: apiKey });
|
|
7084
8720
|
const response = await fetch(`${tmdbBaseUrl}/movie/${tmdbId}?${params.toString()}`);
|
|
7085
8721
|
if (!response.ok) throw new Error(`TMDB movie details failed: ${response.statusText}`);
|
|
7086
8722
|
return await response.json();
|
|
7087
8723
|
}
|
|
7088
8724
|
async function getMovieVideos(tmdbId) {
|
|
7089
|
-
const apiKey = getApiKey();
|
|
8725
|
+
const apiKey = await getApiKey();
|
|
7090
8726
|
const params = new URLSearchParams({ api_key: apiKey });
|
|
7091
8727
|
const response = await fetch(`${tmdbBaseUrl}/movie/${tmdbId}/videos?${params.toString()}`);
|
|
7092
8728
|
if (!response.ok) throw new Error(`TMDB movie videos failed: ${response.statusText}`);
|
|
@@ -7107,7 +8743,7 @@ function getTrailerUrl(videos) {
|
|
|
7107
8743
|
}
|
|
7108
8744
|
async function fetchMovieMetadata(title, year) {
|
|
7109
8745
|
try {
|
|
7110
|
-
getApiKey();
|
|
8746
|
+
await getApiKey();
|
|
7111
8747
|
} catch {
|
|
7112
8748
|
return null;
|
|
7113
8749
|
}
|
|
@@ -7130,7 +8766,7 @@ async function fetchMovieMetadata(title, year) {
|
|
|
7130
8766
|
};
|
|
7131
8767
|
}
|
|
7132
8768
|
async function searchShow(title, year) {
|
|
7133
|
-
const apiKey = getApiKey();
|
|
8769
|
+
const apiKey = await getApiKey();
|
|
7134
8770
|
const params = new URLSearchParams({
|
|
7135
8771
|
api_key: apiKey,
|
|
7136
8772
|
query: title
|
|
@@ -7141,14 +8777,14 @@ async function searchShow(title, year) {
|
|
|
7141
8777
|
return (await response.json()).results;
|
|
7142
8778
|
}
|
|
7143
8779
|
async function getShowDetails(tmdbId) {
|
|
7144
|
-
const apiKey = getApiKey();
|
|
8780
|
+
const apiKey = await getApiKey();
|
|
7145
8781
|
const params = new URLSearchParams({ api_key: apiKey });
|
|
7146
8782
|
const response = await fetch(`${tmdbBaseUrl}/tv/${tmdbId}?${params.toString()}`);
|
|
7147
8783
|
if (!response.ok) throw new Error(`TMDB show details failed: ${response.statusText}`);
|
|
7148
8784
|
return await response.json();
|
|
7149
8785
|
}
|
|
7150
8786
|
async function getSeasonDetails(tmdbId, seasonNumber) {
|
|
7151
|
-
const apiKey = getApiKey();
|
|
8787
|
+
const apiKey = await getApiKey();
|
|
7152
8788
|
const params = new URLSearchParams({ api_key: apiKey });
|
|
7153
8789
|
const response = await fetch(`${tmdbBaseUrl}/tv/${tmdbId}/season/${seasonNumber}?${params.toString()}`);
|
|
7154
8790
|
if (!response.ok) throw new Error(`TMDB season details failed: ${response.statusText}`);
|
|
@@ -7156,7 +8792,7 @@ async function getSeasonDetails(tmdbId, seasonNumber) {
|
|
|
7156
8792
|
}
|
|
7157
8793
|
async function fetchShowMetadata(title, year) {
|
|
7158
8794
|
try {
|
|
7159
|
-
getApiKey();
|
|
8795
|
+
await getApiKey();
|
|
7160
8796
|
} catch {
|
|
7161
8797
|
return null;
|
|
7162
8798
|
}
|
|
@@ -7179,7 +8815,7 @@ async function fetchShowMetadata(title, year) {
|
|
|
7179
8815
|
}
|
|
7180
8816
|
async function fetchSeasonMetadata(tmdbId, seasonNumber) {
|
|
7181
8817
|
try {
|
|
7182
|
-
getApiKey();
|
|
8818
|
+
await getApiKey();
|
|
7183
8819
|
} catch {
|
|
7184
8820
|
return null;
|
|
7185
8821
|
}
|
|
@@ -7199,106 +8835,73 @@ async function fetchSeasonMetadata(tmdbId, seasonNumber) {
|
|
|
7199
8835
|
};
|
|
7200
8836
|
}
|
|
7201
8837
|
//#endregion
|
|
7202
|
-
//#region src/services/
|
|
7203
|
-
/**
|
|
7204
|
-
* Parse a movie filename to extract title and year.
|
|
7205
|
-
* The year marks the boundary between the title and technical info.
|
|
7206
|
-
*
|
|
7207
|
-
* Examples:
|
|
7208
|
-
* - Jurassic.Park.1993.720p.BrRip.264.YIFY.mp4 -> { title: "Jurassic Park", year: 1993 }
|
|
7209
|
-
* - The.Social.Network.(2010).1080p.BrRip.x264.mp4 -> { title: "The Social Network", year: 2010 }
|
|
7210
|
-
*/
|
|
7211
|
-
function parseFilename(fileName) {
|
|
7212
|
-
const name = fileName.replace(/\.[^.]+$/, "");
|
|
7213
|
-
const bracketYearMatch = name.match(/[([]((?:19|20)\d{2})[)\]]/);
|
|
7214
|
-
if (bracketYearMatch?.[1]) {
|
|
7215
|
-
const year = parseInt(bracketYearMatch[1], 10);
|
|
7216
|
-
const yearIndex = bracketYearMatch.index ?? 0;
|
|
7217
|
-
let title = name.substring(0, yearIndex);
|
|
7218
|
-
title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
|
|
7219
|
-
title = title.replace(/[-–—]+$/, "").trim();
|
|
7220
|
-
return {
|
|
7221
|
-
title,
|
|
7222
|
-
year
|
|
7223
|
-
};
|
|
7224
|
-
}
|
|
7225
|
-
const yearPattern = /[.\s]((?:19|20)\d{2})[.\s]/g;
|
|
7226
|
-
let match;
|
|
7227
|
-
let bestMatch = null;
|
|
7228
|
-
while ((match = yearPattern.exec(name)) !== null) {
|
|
7229
|
-
const yearStr = match[1];
|
|
7230
|
-
if (!yearStr) continue;
|
|
7231
|
-
const year = parseInt(yearStr, 10);
|
|
7232
|
-
const afterYear = name.substring(match.index + match[0].length);
|
|
7233
|
-
const looksLikeTechInfo = /^(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|HDTV|DVDRip|x264|x265|H\.?264)/i.test(afterYear);
|
|
7234
|
-
if (looksLikeTechInfo || !bestMatch) {
|
|
7235
|
-
bestMatch = {
|
|
7236
|
-
year,
|
|
7237
|
-
index: match.index
|
|
7238
|
-
};
|
|
7239
|
-
if (looksLikeTechInfo) break;
|
|
7240
|
-
}
|
|
7241
|
-
}
|
|
7242
|
-
if (bestMatch) {
|
|
7243
|
-
let title = name.substring(0, bestMatch.index);
|
|
7244
|
-
title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
|
|
7245
|
-
title = title.replace(/[-–—]+$/, "").trim();
|
|
7246
|
-
return {
|
|
7247
|
-
title,
|
|
7248
|
-
year: bestMatch.year
|
|
7249
|
-
};
|
|
7250
|
-
}
|
|
7251
|
-
return {
|
|
7252
|
-
title: name.replace(/[._]/g, " ").replace(/\s+/g, " ").trim(),
|
|
7253
|
-
year: null
|
|
7254
|
-
};
|
|
7255
|
-
}
|
|
8838
|
+
//#region src/services/subtitle-sync.ts
|
|
7256
8839
|
/**
|
|
7257
|
-
*
|
|
8840
|
+
* Replace the subtitle rows attached to a movie with the freshly discovered
|
|
8841
|
+
* sidecars. We delete-and-reinsert rather than diffing so a renamed or
|
|
8842
|
+
* removed sidecar disappears on the next scan without bookkeeping.
|
|
7258
8843
|
*/
|
|
7259
|
-
function
|
|
7260
|
-
|
|
8844
|
+
async function syncSubtitlesForMovie(movieId, discovered) {
|
|
8845
|
+
await db.delete(subtitles).where(eq(subtitles.movieId, movieId));
|
|
8846
|
+
if (discovered.length === 0) return;
|
|
8847
|
+
const rows = discovered.map((subtitle) => ({
|
|
8848
|
+
movieId,
|
|
8849
|
+
episodeId: null,
|
|
8850
|
+
filePath: subtitle.filePath,
|
|
8851
|
+
format: subtitle.format,
|
|
8852
|
+
language: subtitle.language
|
|
8853
|
+
}));
|
|
8854
|
+
await db.insert(subtitles).values(rows);
|
|
7261
8855
|
}
|
|
7262
8856
|
/**
|
|
7263
|
-
*
|
|
8857
|
+
* Same as syncSubtitlesForMovie but for episodes. See note above.
|
|
7264
8858
|
*/
|
|
7265
|
-
function
|
|
7266
|
-
|
|
8859
|
+
async function syncSubtitlesForEpisode(episodeId, discovered) {
|
|
8860
|
+
await db.delete(subtitles).where(eq(subtitles.episodeId, episodeId));
|
|
8861
|
+
if (discovered.length === 0) return;
|
|
8862
|
+
const rows = discovered.map((subtitle) => ({
|
|
8863
|
+
movieId: null,
|
|
8864
|
+
episodeId,
|
|
8865
|
+
filePath: subtitle.filePath,
|
|
8866
|
+
format: subtitle.format,
|
|
8867
|
+
language: subtitle.language
|
|
8868
|
+
}));
|
|
8869
|
+
await db.insert(subtitles).values(rows);
|
|
7267
8870
|
}
|
|
7268
8871
|
//#endregion
|
|
7269
8872
|
//#region src/services/scanner.ts
|
|
7270
|
-
const VIDEO_EXTENSIONS = new Set([
|
|
7271
|
-
".mp4",
|
|
7272
|
-
".mkv",
|
|
7273
|
-
".avi",
|
|
7274
|
-
".mov",
|
|
7275
|
-
".wmv",
|
|
7276
|
-
".m4v",
|
|
7277
|
-
".webm",
|
|
7278
|
-
".flv",
|
|
7279
|
-
".mpeg",
|
|
7280
|
-
".mpg"
|
|
7281
|
-
]);
|
|
7282
8873
|
/**
|
|
7283
|
-
* Recursively scan a directory for video files
|
|
8874
|
+
* Recursively scan a directory for video files. Each yielded entry includes
|
|
8875
|
+
* the subtitle siblings sitting in the same folder so subtitle discovery
|
|
8876
|
+
* piggybacks on the directory read instead of re-globbing per file.
|
|
7284
8877
|
*/
|
|
7285
8878
|
async function* scanDirectory(dir) {
|
|
7286
8879
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
8880
|
+
const videoFiles = [];
|
|
8881
|
+
const subtitleSiblings = [];
|
|
8882
|
+
const subdirectories = [];
|
|
7287
8883
|
for (const entry of entries) {
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
const ext = extname(entry.name).toLowerCase();
|
|
7292
|
-
if (VIDEO_EXTENSIONS.has(ext)) yield fullPath;
|
|
8884
|
+
if (entry.isDirectory()) {
|
|
8885
|
+
subdirectories.push(entry.name);
|
|
8886
|
+
continue;
|
|
7293
8887
|
}
|
|
7294
|
-
|
|
8888
|
+
if (!entry.isFile()) continue;
|
|
8889
|
+
const extension = extname(entry.name).toLowerCase();
|
|
8890
|
+
if (videoExtensions$1.has(extension)) videoFiles.push(entry.name);
|
|
8891
|
+
else if (subtitleExtensions.has(extension)) subtitleSiblings.push(entry.name);
|
|
8892
|
+
}
|
|
8893
|
+
for (const videoFile of videoFiles) yield {
|
|
8894
|
+
filePath: join(dir, videoFile),
|
|
8895
|
+
subtitleSiblings
|
|
8896
|
+
};
|
|
8897
|
+
for (const subdirectory of subdirectories) yield* scanDirectory(join(dir, subdirectory));
|
|
7295
8898
|
}
|
|
7296
8899
|
async function scanLibrary(libraryPath, onProgress) {
|
|
7297
8900
|
let added = 0;
|
|
7298
8901
|
let updated = 0;
|
|
7299
8902
|
let total = 0;
|
|
7300
8903
|
console.log(`Scanning: ${libraryPath}`);
|
|
7301
|
-
for await (const filePath of scanDirectory(libraryPath)) {
|
|
8904
|
+
for await (const { filePath, subtitleSiblings } of scanDirectory(libraryPath)) {
|
|
7302
8905
|
total++;
|
|
7303
8906
|
const fileName = basename(filePath);
|
|
7304
8907
|
const ext = extname(fileName).toLowerCase();
|
|
@@ -7309,9 +8912,12 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
7309
8912
|
fileSize = (await stat(filePath)).size;
|
|
7310
8913
|
} catch {}
|
|
7311
8914
|
const now = /* @__PURE__ */ new Date();
|
|
8915
|
+
const discoveredSubtitles = discoverSubtitlesForVideo(filePath, subtitleSiblings);
|
|
7312
8916
|
const existing = await db.select().from(movies).where(eq(movies.filePath, filePath)).limit(1);
|
|
8917
|
+
let movieId;
|
|
7313
8918
|
if (existing.length > 0 && existing[0]) {
|
|
7314
8919
|
const movie = existing[0];
|
|
8920
|
+
movieId = movie.id;
|
|
7315
8921
|
let tmdbData = {};
|
|
7316
8922
|
if (!movie.tmdbId) {
|
|
7317
8923
|
console.log(` Fetching TMDB metadata for: ${title}`);
|
|
@@ -7369,7 +8975,7 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
7369
8975
|
backdropPath: metadata?.backdropPath ?? null,
|
|
7370
8976
|
trailerUrl: metadata?.trailerUrl ?? null
|
|
7371
8977
|
};
|
|
7372
|
-
await db.insert(movies).values(newMovie);
|
|
8978
|
+
movieId = (await db.insert(movies).values(newMovie).returning({ id: movies.id }))[0]?.id ?? null;
|
|
7373
8979
|
added++;
|
|
7374
8980
|
await onProgress?.({
|
|
7375
8981
|
added,
|
|
@@ -7377,6 +8983,7 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
7377
8983
|
updated
|
|
7378
8984
|
});
|
|
7379
8985
|
}
|
|
8986
|
+
if (movieId !== null) await syncSubtitlesForMovie(movieId, discoveredSubtitles);
|
|
7380
8987
|
console.log(` Found: ${title}`);
|
|
7381
8988
|
}
|
|
7382
8989
|
return {
|
|
@@ -7387,7 +8994,7 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
7387
8994
|
}
|
|
7388
8995
|
//#endregion
|
|
7389
8996
|
//#region src/services/episode-parser.ts
|
|
7390
|
-
const videoExtensions
|
|
8997
|
+
const videoExtensions = new Set([
|
|
7391
8998
|
".mp4",
|
|
7392
8999
|
".mkv",
|
|
7393
9000
|
".avi",
|
|
@@ -7403,7 +9010,7 @@ const episodePattern = /S(\d+)[Ee](\d+)/;
|
|
|
7403
9010
|
const technicalPatterns = /\b(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|WEB-DL|HDTV|DVDRip|x264|x265|H\.?264|HEVC|AAC|AC3|DD5|10bit)\b/i;
|
|
7404
9011
|
function parseEpisodeFilename(fileName) {
|
|
7405
9012
|
const ext = extname(fileName).toLowerCase();
|
|
7406
|
-
if (!videoExtensions
|
|
9013
|
+
if (!videoExtensions.has(ext)) return null;
|
|
7407
9014
|
const name = fileName.replace(/\.[^.]+$/, "");
|
|
7408
9015
|
const match = name.match(episodePattern);
|
|
7409
9016
|
if (!match?.[1] || !match[2]) return null;
|
|
@@ -7497,18 +9104,6 @@ function parseSeasonFolder(folderName) {
|
|
|
7497
9104
|
}
|
|
7498
9105
|
//#endregion
|
|
7499
9106
|
//#region src/services/show-scanner.ts
|
|
7500
|
-
const videoExtensions = new Set([
|
|
7501
|
-
".mp4",
|
|
7502
|
-
".mkv",
|
|
7503
|
-
".avi",
|
|
7504
|
-
".mov",
|
|
7505
|
-
".wmv",
|
|
7506
|
-
".m4v",
|
|
7507
|
-
".webm",
|
|
7508
|
-
".flv",
|
|
7509
|
-
".mpeg",
|
|
7510
|
-
".mpg"
|
|
7511
|
-
]);
|
|
7512
9107
|
async function scanEpisodesInDirectory(directory) {
|
|
7513
9108
|
let entries;
|
|
7514
9109
|
try {
|
|
@@ -7516,10 +9111,16 @@ async function scanEpisodesInDirectory(directory) {
|
|
|
7516
9111
|
} catch {
|
|
7517
9112
|
return [];
|
|
7518
9113
|
}
|
|
7519
|
-
const
|
|
9114
|
+
const subtitleSiblings = [];
|
|
9115
|
+
const videoEntries = [];
|
|
7520
9116
|
for (const entry of entries) {
|
|
7521
9117
|
const extension = extname(entry).toLowerCase();
|
|
7522
|
-
if (
|
|
9118
|
+
if (videoExtensions$1.has(extension)) videoEntries.push(entry);
|
|
9119
|
+
else if (subtitleExtensions.has(extension)) subtitleSiblings.push(entry);
|
|
9120
|
+
}
|
|
9121
|
+
const episodes = [];
|
|
9122
|
+
for (const entry of videoEntries) {
|
|
9123
|
+
const extension = extname(entry).toLowerCase();
|
|
7523
9124
|
const parsed = parseEpisodeFilename(entry);
|
|
7524
9125
|
if (!parsed) continue;
|
|
7525
9126
|
const filePath = join(directory, entry);
|
|
@@ -7534,7 +9135,8 @@ async function scanEpisodesInDirectory(directory) {
|
|
|
7534
9135
|
extension,
|
|
7535
9136
|
seasonNumber: parsed.seasonNumber,
|
|
7536
9137
|
episodeNumber: parsed.episodeNumber,
|
|
7537
|
-
title: parsed.title
|
|
9138
|
+
title: parsed.title,
|
|
9139
|
+
subtitleSiblings
|
|
7538
9140
|
});
|
|
7539
9141
|
}
|
|
7540
9142
|
return episodes;
|
|
@@ -7694,7 +9296,9 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
7694
9296
|
const episodeTitle = tmdbEpisode?.title ?? scannedEpisode.title ?? `Episode ${scannedEpisode.episodeNumber}`;
|
|
7695
9297
|
const existingEpisodes = await db.select().from(episodes).where(eq(episodes.filePath, scannedEpisode.filePath)).limit(1);
|
|
7696
9298
|
const now = /* @__PURE__ */ new Date();
|
|
7697
|
-
|
|
9299
|
+
let episodeId;
|
|
9300
|
+
if (existingEpisodes.length > 0 && existingEpisodes[0]) {
|
|
9301
|
+
episodeId = existingEpisodes[0].id;
|
|
7698
9302
|
await db.update(episodes).set({
|
|
7699
9303
|
title: episodeTitle,
|
|
7700
9304
|
fileName: scannedEpisode.fileName,
|
|
@@ -7726,7 +9330,7 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
7726
9330
|
createdAt: now,
|
|
7727
9331
|
updatedAt: now
|
|
7728
9332
|
};
|
|
7729
|
-
await db.insert(episodes).values(newEpisode);
|
|
9333
|
+
episodeId = (await db.insert(episodes).values(newEpisode).returning({ id: episodes.id }))[0]?.id ?? null;
|
|
7730
9334
|
added++;
|
|
7731
9335
|
await onProgress?.({
|
|
7732
9336
|
added,
|
|
@@ -7734,6 +9338,10 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
7734
9338
|
updated
|
|
7735
9339
|
});
|
|
7736
9340
|
}
|
|
9341
|
+
if (episodeId !== null) {
|
|
9342
|
+
const discoveredSubtitles = discoverSubtitlesForVideo(scannedEpisode.filePath, scannedEpisode.subtitleSiblings);
|
|
9343
|
+
await syncSubtitlesForEpisode(episodeId, discoveredSubtitles);
|
|
9344
|
+
}
|
|
7737
9345
|
}
|
|
7738
9346
|
}
|
|
7739
9347
|
console.log(` Found show: ${name} (${episodes$1.length} episodes)`);
|
|
@@ -7745,85 +9353,746 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
7745
9353
|
};
|
|
7746
9354
|
}
|
|
7747
9355
|
//#endregion
|
|
7748
|
-
//#region src/
|
|
7749
|
-
const
|
|
7750
|
-
|
|
7751
|
-
const
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
|
|
7761
|
-
|
|
7762
|
-
|
|
9356
|
+
//#region src/services/scan-manager.ts
|
|
9357
|
+
const interruptedMessage = "Server restarted mid-scan. Run a manual scan to resume.";
|
|
9358
|
+
function createScanManager(dependencies = {}) {
|
|
9359
|
+
const database = dependencies.database ?? db;
|
|
9360
|
+
const scanLibrary$1 = dependencies.scanLibrary ?? scanLibrary;
|
|
9361
|
+
const scanShowLibrary$1 = dependencies.scanShowLibrary ?? scanShowLibrary;
|
|
9362
|
+
const emitter = new EventEmitter();
|
|
9363
|
+
emitter.setMaxListeners(0);
|
|
9364
|
+
let currentJob = null;
|
|
9365
|
+
async function getStatus() {
|
|
9366
|
+
if (currentJob) {
|
|
9367
|
+
const [latest] = await database.select().from(scanJobs).where(eq(scanJobs.id, currentJob.id)).limit(1);
|
|
9368
|
+
return {
|
|
9369
|
+
currentJob: latest ?? currentJob,
|
|
9370
|
+
isRunning: true,
|
|
9371
|
+
lastJob: latest ?? currentJob
|
|
9372
|
+
};
|
|
9373
|
+
}
|
|
9374
|
+
const [last] = await database.select().from(scanJobs).orderBy(desc(scanJobs.startedAt)).limit(1);
|
|
9375
|
+
return {
|
|
9376
|
+
currentJob: null,
|
|
9377
|
+
isRunning: false,
|
|
9378
|
+
lastJob: last ?? null
|
|
9379
|
+
};
|
|
9380
|
+
}
|
|
9381
|
+
function isRunning() {
|
|
9382
|
+
return currentJob !== null;
|
|
9383
|
+
}
|
|
9384
|
+
async function recoverInterruptedJobs() {
|
|
9385
|
+
const now = /* @__PURE__ */ new Date();
|
|
9386
|
+
const running = await database.select().from(scanJobs).where(eq(scanJobs.status, "running"));
|
|
9387
|
+
if (running.length === 0) return;
|
|
9388
|
+
for (const job of running) await database.update(scanJobs).set({
|
|
9389
|
+
endedAt: now,
|
|
9390
|
+
errorMessage: interruptedMessage,
|
|
9391
|
+
status: "error"
|
|
9392
|
+
}).where(eq(scanJobs.id, job.id));
|
|
9393
|
+
log.info(`Marked ${running.length} interrupted scan job(s) as failed after a restart.`);
|
|
9394
|
+
}
|
|
9395
|
+
async function start() {
|
|
9396
|
+
if (currentJob) return { status: "already-running" };
|
|
9397
|
+
const libraries$4 = await database.select().from(libraries);
|
|
9398
|
+
if (libraries$4.length === 0) return {
|
|
9399
|
+
status: "no-libraries",
|
|
9400
|
+
message: "No libraries configured. Add a library in Settings before scanning."
|
|
9401
|
+
};
|
|
9402
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
9403
|
+
const [inserted] = await database.insert(scanJobs).values({
|
|
9404
|
+
startedAt,
|
|
9405
|
+
status: "running",
|
|
9406
|
+
added: 0,
|
|
9407
|
+
updated: 0,
|
|
9408
|
+
total: 0
|
|
9409
|
+
}).returning();
|
|
9410
|
+
invariant(inserted, "Failed to create scan_jobs row.");
|
|
9411
|
+
currentJob = inserted;
|
|
9412
|
+
emitter.emit("job-started", {
|
|
9413
|
+
jobId: inserted.id,
|
|
9414
|
+
startedAt: inserted.startedAt
|
|
9415
|
+
});
|
|
7763
9416
|
let totalAdded = 0;
|
|
7764
9417
|
let totalUpdated = 0;
|
|
7765
9418
|
let totalFound = 0;
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
9419
|
+
const libraryErrors = [];
|
|
9420
|
+
try {
|
|
9421
|
+
for (let index = 0; index < libraries$4.length; index++) {
|
|
9422
|
+
const library = libraries$4[index];
|
|
9423
|
+
if (!library) continue;
|
|
9424
|
+
const libraryType = library.type === "shows" ? "shows" : "movies";
|
|
9425
|
+
emitter.emit("library-start", {
|
|
9426
|
+
index,
|
|
9427
|
+
libraryId: library.id,
|
|
9428
|
+
name: library.name,
|
|
9429
|
+
type: libraryType
|
|
9430
|
+
});
|
|
7774
9431
|
const onProgress = async (progress) => {
|
|
7775
|
-
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
9432
|
+
const aggregate = {
|
|
9433
|
+
added: totalAdded + progress.added,
|
|
9434
|
+
total: totalFound + progress.total,
|
|
9435
|
+
updated: totalUpdated + progress.updated
|
|
9436
|
+
};
|
|
9437
|
+
await database.update(scanJobs).set(aggregate).where(eq(scanJobs.id, inserted.id));
|
|
9438
|
+
emitter.emit("file-scanned", {
|
|
9439
|
+
...progress,
|
|
9440
|
+
index,
|
|
9441
|
+
libraryId: library.id
|
|
7781
9442
|
});
|
|
7782
9443
|
};
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
error: error instanceof Error ? error.message : "Unknown error",
|
|
7803
|
-
index: i
|
|
7804
|
-
})
|
|
7805
|
-
});
|
|
9444
|
+
try {
|
|
9445
|
+
const result = libraryType === "shows" ? await scanShowLibrary$1(library.path, onProgress) : await scanLibrary$1(library.path, onProgress);
|
|
9446
|
+
totalAdded += result.added;
|
|
9447
|
+
totalUpdated += result.updated;
|
|
9448
|
+
totalFound += result.total;
|
|
9449
|
+
emitter.emit("library-complete", {
|
|
9450
|
+
...result,
|
|
9451
|
+
index,
|
|
9452
|
+
libraryId: library.id
|
|
9453
|
+
});
|
|
9454
|
+
} catch (caughtError) {
|
|
9455
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9456
|
+
libraryErrors.push(`${library.name} — ${message}`);
|
|
9457
|
+
emitter.emit("library-error", {
|
|
9458
|
+
error: message,
|
|
9459
|
+
index,
|
|
9460
|
+
libraryId: library.id
|
|
9461
|
+
});
|
|
9462
|
+
}
|
|
7806
9463
|
}
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
9464
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
9465
|
+
const status = libraryErrors.length > 0 ? "error" : "done";
|
|
9466
|
+
const errorMessage = libraryErrors.length > 0 ? libraryErrors.join("; ") : null;
|
|
9467
|
+
await database.update(scanJobs).set({
|
|
7811
9468
|
added: totalAdded,
|
|
7812
|
-
|
|
9469
|
+
endedAt,
|
|
9470
|
+
errorMessage,
|
|
9471
|
+
status,
|
|
9472
|
+
total: totalFound,
|
|
7813
9473
|
updated: totalUpdated
|
|
7814
|
-
})
|
|
9474
|
+
}).where(eq(scanJobs.id, inserted.id));
|
|
9475
|
+
const payload = {
|
|
9476
|
+
added: totalAdded,
|
|
9477
|
+
errorMessage,
|
|
9478
|
+
jobId: inserted.id,
|
|
9479
|
+
status,
|
|
9480
|
+
total: totalFound,
|
|
9481
|
+
updated: totalUpdated
|
|
9482
|
+
};
|
|
9483
|
+
emitter.emit("job-completed", payload);
|
|
9484
|
+
return {
|
|
9485
|
+
added: totalAdded,
|
|
9486
|
+
errorMessage: errorMessage ?? void 0,
|
|
9487
|
+
status,
|
|
9488
|
+
total: totalFound,
|
|
9489
|
+
updated: totalUpdated
|
|
9490
|
+
};
|
|
9491
|
+
} catch (caughtError) {
|
|
9492
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
9493
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9494
|
+
await database.update(scanJobs).set({
|
|
9495
|
+
added: totalAdded,
|
|
9496
|
+
endedAt,
|
|
9497
|
+
errorMessage: message,
|
|
9498
|
+
status: "error",
|
|
9499
|
+
total: totalFound,
|
|
9500
|
+
updated: totalUpdated
|
|
9501
|
+
}).where(eq(scanJobs.id, inserted.id));
|
|
9502
|
+
emitter.emit("job-failed", {
|
|
9503
|
+
error: message,
|
|
9504
|
+
jobId: inserted.id
|
|
9505
|
+
});
|
|
9506
|
+
return {
|
|
9507
|
+
added: totalAdded,
|
|
9508
|
+
errorMessage: message,
|
|
9509
|
+
status: "error",
|
|
9510
|
+
total: totalFound,
|
|
9511
|
+
updated: totalUpdated
|
|
9512
|
+
};
|
|
9513
|
+
} finally {
|
|
9514
|
+
currentJob = null;
|
|
9515
|
+
}
|
|
9516
|
+
}
|
|
9517
|
+
return {
|
|
9518
|
+
getStatus,
|
|
9519
|
+
isRunning,
|
|
9520
|
+
off: (event, listener) => emitter.off(event, listener),
|
|
9521
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
9522
|
+
recoverInterruptedJobs,
|
|
9523
|
+
start
|
|
9524
|
+
};
|
|
9525
|
+
}
|
|
9526
|
+
const scanManager = createScanManager();
|
|
9527
|
+
//#endregion
|
|
9528
|
+
//#region src/api/routes/scan.ts
|
|
9529
|
+
const scanRoutes = new Hono();
|
|
9530
|
+
scanRoutes.get("/libraries", async (context) => {
|
|
9531
|
+
const libraries$3 = await db.select().from(libraries);
|
|
9532
|
+
return context.json(libraries$3.map((library) => ({
|
|
9533
|
+
id: library.id,
|
|
9534
|
+
name: library.name,
|
|
9535
|
+
path: library.path,
|
|
9536
|
+
type: library.type
|
|
9537
|
+
})));
|
|
9538
|
+
});
|
|
9539
|
+
scanRoutes.get("/status", async (context) => {
|
|
9540
|
+
const status = await scanManager.getStatus();
|
|
9541
|
+
return context.json({
|
|
9542
|
+
currentJob: status.currentJob ? {
|
|
9543
|
+
added: status.currentJob.added,
|
|
9544
|
+
endedAt: status.currentJob.endedAt?.toISOString() ?? null,
|
|
9545
|
+
errorMessage: status.currentJob.errorMessage,
|
|
9546
|
+
id: status.currentJob.id,
|
|
9547
|
+
startedAt: status.currentJob.startedAt.toISOString(),
|
|
9548
|
+
status: status.currentJob.status,
|
|
9549
|
+
total: status.currentJob.total,
|
|
9550
|
+
updated: status.currentJob.updated
|
|
9551
|
+
} : null,
|
|
9552
|
+
isRunning: status.isRunning,
|
|
9553
|
+
lastJob: status.lastJob ? {
|
|
9554
|
+
added: status.lastJob.added,
|
|
9555
|
+
endedAt: status.lastJob.endedAt?.toISOString() ?? null,
|
|
9556
|
+
errorMessage: status.lastJob.errorMessage,
|
|
9557
|
+
id: status.lastJob.id,
|
|
9558
|
+
startedAt: status.lastJob.startedAt.toISOString(),
|
|
9559
|
+
status: status.lastJob.status,
|
|
9560
|
+
total: status.lastJob.total,
|
|
9561
|
+
updated: status.lastJob.updated
|
|
9562
|
+
} : null
|
|
9563
|
+
});
|
|
9564
|
+
});
|
|
9565
|
+
scanRoutes.post("/start", async (context) => {
|
|
9566
|
+
if ((await db.select().from(libraries)).length === 0) return context.json({ error: { message: "No libraries configured. Add a library in Settings before scanning." } }, 400);
|
|
9567
|
+
if (scanManager.isRunning()) return context.json({ status: "already-running" });
|
|
9568
|
+
scanManager.start().catch((caughtError) => {
|
|
9569
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9570
|
+
console.error(`Manual scan failed: ${message}`);
|
|
9571
|
+
});
|
|
9572
|
+
return context.json({ status: "started" });
|
|
9573
|
+
});
|
|
9574
|
+
scanRoutes.get("/stream", (context) => {
|
|
9575
|
+
return streamSSE(context, async (stream) => {
|
|
9576
|
+
const onLibraryStart = (event) => {
|
|
9577
|
+
stream.writeSSE({
|
|
9578
|
+
event: "library-start",
|
|
9579
|
+
data: JSON.stringify(event)
|
|
9580
|
+
});
|
|
9581
|
+
};
|
|
9582
|
+
const onFileScanned = (event) => {
|
|
9583
|
+
stream.writeSSE({
|
|
9584
|
+
event: "file-scanned",
|
|
9585
|
+
data: JSON.stringify(event)
|
|
9586
|
+
});
|
|
9587
|
+
};
|
|
9588
|
+
const onLibraryComplete = (event) => {
|
|
9589
|
+
stream.writeSSE({
|
|
9590
|
+
event: "library-complete",
|
|
9591
|
+
data: JSON.stringify(event)
|
|
9592
|
+
});
|
|
9593
|
+
};
|
|
9594
|
+
const onLibraryError = (event) => {
|
|
9595
|
+
stream.writeSSE({
|
|
9596
|
+
event: "library-error",
|
|
9597
|
+
data: JSON.stringify(event)
|
|
9598
|
+
});
|
|
9599
|
+
};
|
|
9600
|
+
const onScanComplete = (event) => {
|
|
9601
|
+
stream.writeSSE({
|
|
9602
|
+
event: "scan-complete",
|
|
9603
|
+
data: JSON.stringify({
|
|
9604
|
+
added: event.added,
|
|
9605
|
+
errorMessage: event.errorMessage,
|
|
9606
|
+
found: event.total,
|
|
9607
|
+
status: event.status,
|
|
9608
|
+
updated: event.updated
|
|
9609
|
+
})
|
|
9610
|
+
});
|
|
9611
|
+
};
|
|
9612
|
+
scanManager.on("library-start", onLibraryStart);
|
|
9613
|
+
scanManager.on("file-scanned", onFileScanned);
|
|
9614
|
+
scanManager.on("library-complete", onLibraryComplete);
|
|
9615
|
+
scanManager.on("library-error", onLibraryError);
|
|
9616
|
+
scanManager.on("job-completed", onScanComplete);
|
|
9617
|
+
await stream.writeSSE({
|
|
9618
|
+
event: "ready",
|
|
9619
|
+
data: JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString() })
|
|
7815
9620
|
});
|
|
9621
|
+
stream.onAbort(() => {
|
|
9622
|
+
scanManager.off("library-start", onLibraryStart);
|
|
9623
|
+
scanManager.off("file-scanned", onFileScanned);
|
|
9624
|
+
scanManager.off("library-complete", onLibraryComplete);
|
|
9625
|
+
scanManager.off("library-error", onLibraryError);
|
|
9626
|
+
scanManager.off("job-completed", onScanComplete);
|
|
9627
|
+
});
|
|
9628
|
+
const keepAliveMs = 15e3;
|
|
9629
|
+
try {
|
|
9630
|
+
while (!stream.aborted && !stream.closed) {
|
|
9631
|
+
await stream.sleep(keepAliveMs);
|
|
9632
|
+
if (stream.aborted || stream.closed) break;
|
|
9633
|
+
await stream.writeSSE({
|
|
9634
|
+
event: "ping",
|
|
9635
|
+
data: "{}"
|
|
9636
|
+
});
|
|
9637
|
+
}
|
|
9638
|
+
} catch {}
|
|
9639
|
+
});
|
|
9640
|
+
});
|
|
9641
|
+
//#endregion
|
|
9642
|
+
//#region src/services/fts-query-parser.ts
|
|
9643
|
+
/**
|
|
9644
|
+
* Convert raw user input into a safe SQLite FTS5 MATCH expression.
|
|
9645
|
+
*
|
|
9646
|
+
* FTS5 has a query syntax with operators (AND, OR, NEAR, NOT), prefix
|
|
9647
|
+
* wildcards (*), column filters (title:foo), and quoted phrases. We don't
|
|
9648
|
+
* want users to accidentally trigger any of that — and we don't want
|
|
9649
|
+
* adversarial input to crash the parser. So we:
|
|
9650
|
+
*
|
|
9651
|
+
* 1. Split the input on whitespace.
|
|
9652
|
+
* 2. Strip every character except letters, digits, and the underscore
|
|
9653
|
+
* from each token. This drops quotes, asterisks, parens, colons,
|
|
9654
|
+
* and operator-like punctuation.
|
|
9655
|
+
* 3. Wrap each surviving token in double quotes (so reserved words like
|
|
9656
|
+
* `OR` or `NEAR` are treated as literal terms) and append `*` for
|
|
9657
|
+
* prefix matching.
|
|
9658
|
+
* 4. Join the tokens with spaces. FTS5 implicitly ANDs them.
|
|
9659
|
+
*
|
|
9660
|
+
* Returns null when there is nothing searchable (empty input, only
|
|
9661
|
+
* whitespace, or only stripped characters).
|
|
9662
|
+
*/
|
|
9663
|
+
function buildFtsMatchQuery(rawInput) {
|
|
9664
|
+
const tokens = rawInput.split(/\s+/).map((token) => sanitizeToken(token)).filter((token) => token.length > 0);
|
|
9665
|
+
if (tokens.length === 0) return null;
|
|
9666
|
+
return tokens.map((token) => `"${token}"*`).join(" ");
|
|
9667
|
+
}
|
|
9668
|
+
function sanitizeToken(token) {
|
|
9669
|
+
return token.replace(/[^\p{L}\p{N}_]+/gu, "");
|
|
9670
|
+
}
|
|
9671
|
+
//#endregion
|
|
9672
|
+
//#region src/api/routes/search.ts
|
|
9673
|
+
const defaultLimit = 20;
|
|
9674
|
+
const maxLimit = 50;
|
|
9675
|
+
const maxQueryLength = 256;
|
|
9676
|
+
const searchRoutes = new Hono();
|
|
9677
|
+
searchRoutes.get("/", (context) => {
|
|
9678
|
+
const queryParameter = context.req.query("q");
|
|
9679
|
+
if (queryParameter === void 0) return context.json({ error: { message: "Add a `q` query parameter to search. Example: /api/search?q=dune" } }, 400);
|
|
9680
|
+
if (queryParameter.length > maxQueryLength) return context.json({ error: { message: "Search query is too long. Shorten it to under 256 characters." } }, 400);
|
|
9681
|
+
const limit = parseLimit(context.req.query("limit"));
|
|
9682
|
+
if (limit === null) return context.json({ error: { message: "`limit` must be a positive integer between 1 and 50." } }, 400);
|
|
9683
|
+
const matchExpression = buildFtsMatchQuery(queryParameter);
|
|
9684
|
+
if (matchExpression === null) return context.json({
|
|
9685
|
+
episodes: [],
|
|
9686
|
+
indexEmpty: false,
|
|
9687
|
+
movies: [],
|
|
9688
|
+
shows: []
|
|
7816
9689
|
});
|
|
9690
|
+
try {
|
|
9691
|
+
const movies = searchMovies(matchExpression, limit);
|
|
9692
|
+
const shows = searchShows(matchExpression, limit);
|
|
9693
|
+
const episodes = searchEpisodes(matchExpression, limit);
|
|
9694
|
+
const response = {
|
|
9695
|
+
episodes,
|
|
9696
|
+
indexEmpty: movies.length === 0 && shows.length === 0 && episodes.length === 0 && isLibraryEmpty(),
|
|
9697
|
+
movies,
|
|
9698
|
+
shows
|
|
9699
|
+
};
|
|
9700
|
+
return context.json(response);
|
|
9701
|
+
} catch (error) {
|
|
9702
|
+
log.error("Search query failed.", error);
|
|
9703
|
+
return context.json({ error: { message: "Your search couldn't be parsed. Try simpler terms or fewer special characters." } }, 400);
|
|
9704
|
+
}
|
|
9705
|
+
});
|
|
9706
|
+
function isLibraryEmpty() {
|
|
9707
|
+
const [movieCountRow] = db.all(sql`
|
|
9708
|
+
SELECT COUNT(*) AS count FROM movies
|
|
9709
|
+
`);
|
|
9710
|
+
const [showCountRow] = db.all(sql`
|
|
9711
|
+
SELECT COUNT(*) AS count FROM shows
|
|
9712
|
+
`);
|
|
9713
|
+
const [episodeCountRow] = db.all(sql`
|
|
9714
|
+
SELECT COUNT(*) AS count FROM episodes
|
|
9715
|
+
`);
|
|
9716
|
+
return (movieCountRow?.count ?? 0) + (showCountRow?.count ?? 0) + (episodeCountRow?.count ?? 0) === 0;
|
|
9717
|
+
}
|
|
9718
|
+
function parseLimit(raw) {
|
|
9719
|
+
if (raw === void 0 || raw.trim() === "") return defaultLimit;
|
|
9720
|
+
const parsed = Number(raw);
|
|
9721
|
+
if (!Number.isInteger(parsed) || parsed < 1) return null;
|
|
9722
|
+
return Math.min(parsed, maxLimit);
|
|
9723
|
+
}
|
|
9724
|
+
function searchMovies(matchExpression, limit) {
|
|
9725
|
+
return db.all(sql`
|
|
9726
|
+
SELECT
|
|
9727
|
+
movies.id AS id,
|
|
9728
|
+
movies.title AS title,
|
|
9729
|
+
movies.year AS year,
|
|
9730
|
+
movies.overview AS overview,
|
|
9731
|
+
movies.poster_path AS poster_path,
|
|
9732
|
+
movies.backdrop_path AS backdrop_path
|
|
9733
|
+
FROM movies_fts
|
|
9734
|
+
JOIN movies ON movies.rowid = movies_fts.rowid
|
|
9735
|
+
WHERE movies_fts MATCH ${matchExpression}
|
|
9736
|
+
ORDER BY bm25(movies_fts) ASC
|
|
9737
|
+
LIMIT ${limit}
|
|
9738
|
+
`).map((row) => ({
|
|
9739
|
+
backdropPath: row.backdrop_path,
|
|
9740
|
+
id: row.id,
|
|
9741
|
+
overview: row.overview,
|
|
9742
|
+
posterPath: row.poster_path,
|
|
9743
|
+
title: row.title,
|
|
9744
|
+
year: row.year
|
|
9745
|
+
}));
|
|
9746
|
+
}
|
|
9747
|
+
function searchShows(matchExpression, limit) {
|
|
9748
|
+
return db.all(sql`
|
|
9749
|
+
SELECT
|
|
9750
|
+
shows.id AS id,
|
|
9751
|
+
shows.title AS title,
|
|
9752
|
+
shows.year AS year,
|
|
9753
|
+
shows.overview AS overview,
|
|
9754
|
+
shows.poster_path AS poster_path,
|
|
9755
|
+
shows.backdrop_path AS backdrop_path
|
|
9756
|
+
FROM shows_fts
|
|
9757
|
+
JOIN shows ON shows.rowid = shows_fts.rowid
|
|
9758
|
+
WHERE shows_fts MATCH ${matchExpression}
|
|
9759
|
+
ORDER BY bm25(shows_fts) ASC
|
|
9760
|
+
LIMIT ${limit}
|
|
9761
|
+
`).map((row) => ({
|
|
9762
|
+
backdropPath: row.backdrop_path,
|
|
9763
|
+
id: row.id,
|
|
9764
|
+
overview: row.overview,
|
|
9765
|
+
posterPath: row.poster_path,
|
|
9766
|
+
title: row.title,
|
|
9767
|
+
year: row.year
|
|
9768
|
+
}));
|
|
9769
|
+
}
|
|
9770
|
+
function searchEpisodes(matchExpression, limit) {
|
|
9771
|
+
return db.all(sql`
|
|
9772
|
+
SELECT
|
|
9773
|
+
episodes.id AS id,
|
|
9774
|
+
episodes.show_id AS show_id,
|
|
9775
|
+
episodes.season_number AS season_number,
|
|
9776
|
+
episodes.episode_number AS episode_number,
|
|
9777
|
+
episodes.title AS title,
|
|
9778
|
+
episodes.overview AS overview,
|
|
9779
|
+
episodes.still_path AS still_path,
|
|
9780
|
+
shows.title AS show_title
|
|
9781
|
+
FROM episodes_fts
|
|
9782
|
+
JOIN episodes ON episodes.rowid = episodes_fts.rowid
|
|
9783
|
+
LEFT JOIN shows ON shows.id = episodes.show_id
|
|
9784
|
+
WHERE episodes_fts MATCH ${matchExpression}
|
|
9785
|
+
ORDER BY bm25(episodes_fts) ASC
|
|
9786
|
+
LIMIT ${limit}
|
|
9787
|
+
`).map((row) => ({
|
|
9788
|
+
episodeNumber: row.episode_number,
|
|
9789
|
+
id: row.id,
|
|
9790
|
+
overview: row.overview,
|
|
9791
|
+
seasonNumber: row.season_number,
|
|
9792
|
+
showId: row.show_id,
|
|
9793
|
+
showTitle: row.show_title ?? "",
|
|
9794
|
+
stillPath: row.still_path,
|
|
9795
|
+
title: row.title
|
|
9796
|
+
}));
|
|
9797
|
+
}
|
|
9798
|
+
//#endregion
|
|
9799
|
+
//#region src/services/scheduler.ts
|
|
9800
|
+
const millisecondsPerHour = 3600 * 1e3;
|
|
9801
|
+
function scheduleIntervalMilliseconds(value) {
|
|
9802
|
+
switch (value) {
|
|
9803
|
+
case "off": return null;
|
|
9804
|
+
case "6h": return 6 * millisecondsPerHour;
|
|
9805
|
+
case "12h": return 12 * millisecondsPerHour;
|
|
9806
|
+
case "24h": return 24 * millisecondsPerHour;
|
|
9807
|
+
}
|
|
9808
|
+
}
|
|
9809
|
+
async function defaultGetSchedule() {
|
|
9810
|
+
const stored = await getSetting(scanScheduleSettingKey);
|
|
9811
|
+
if (stored === null) return "off";
|
|
9812
|
+
if (isScanScheduleValue(stored)) return stored;
|
|
9813
|
+
log.warn(`Unknown scanSchedule value "${stored}" in settings. Falling back to "off".`);
|
|
9814
|
+
return "off";
|
|
9815
|
+
}
|
|
9816
|
+
function createScheduler(dependencies = {}) {
|
|
9817
|
+
const getSchedule = dependencies.getSchedule ?? defaultGetSchedule;
|
|
9818
|
+
const scanManager$1 = dependencies.scanManager ?? {
|
|
9819
|
+
isRunning: () => scanManager.isRunning(),
|
|
9820
|
+
start: () => scanManager.start()
|
|
9821
|
+
};
|
|
9822
|
+
let currentSchedule = "off";
|
|
9823
|
+
let nextRunAt = null;
|
|
9824
|
+
let timer = null;
|
|
9825
|
+
function clearTimer() {
|
|
9826
|
+
if (timer !== null) {
|
|
9827
|
+
clearTimeout(timer);
|
|
9828
|
+
timer = null;
|
|
9829
|
+
}
|
|
9830
|
+
}
|
|
9831
|
+
function scheduleNext(intervalMs) {
|
|
9832
|
+
clearTimer();
|
|
9833
|
+
nextRunAt = new Date(Date.now() + intervalMs);
|
|
9834
|
+
timer = setTimeout(() => {
|
|
9835
|
+
tick();
|
|
9836
|
+
}, intervalMs);
|
|
9837
|
+
}
|
|
9838
|
+
async function tick() {
|
|
9839
|
+
if (scanManager$1.isRunning()) log.debug("Scheduler tick skipped — a scan is already running.");
|
|
9840
|
+
else try {
|
|
9841
|
+
const result = await scanManager$1.start();
|
|
9842
|
+
if (result.status === "error") {
|
|
9843
|
+
const message = "errorMessage" in result && result.errorMessage ? `: ${result.errorMessage}` : ".";
|
|
9844
|
+
log.warn(`Scheduled scan finished with errors${message}`);
|
|
9845
|
+
} else if (result.status === "no-libraries") log.debug("Scheduled scan skipped — no libraries configured.");
|
|
9846
|
+
} catch (caughtError) {
|
|
9847
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9848
|
+
log.warn(`Scheduled scan failed to start: ${message}. Check the scan page.`);
|
|
9849
|
+
}
|
|
9850
|
+
const activeInterval = scheduleIntervalMilliseconds(currentSchedule);
|
|
9851
|
+
if (activeInterval === null) {
|
|
9852
|
+
nextRunAt = null;
|
|
9853
|
+
return;
|
|
9854
|
+
}
|
|
9855
|
+
scheduleNext(activeInterval);
|
|
9856
|
+
}
|
|
9857
|
+
function applySchedule(value) {
|
|
9858
|
+
currentSchedule = value;
|
|
9859
|
+
const intervalMs = scheduleIntervalMilliseconds(value);
|
|
9860
|
+
if (intervalMs === null) {
|
|
9861
|
+
clearTimer();
|
|
9862
|
+
nextRunAt = null;
|
|
9863
|
+
log.info("Scan scheduler is off.");
|
|
9864
|
+
return;
|
|
9865
|
+
}
|
|
9866
|
+
scheduleNext(intervalMs);
|
|
9867
|
+
log.info(`Next scheduled scan at ${nextRunAt?.toISOString() ?? "unknown"} (${value}).`);
|
|
9868
|
+
}
|
|
9869
|
+
return {
|
|
9870
|
+
getInfo: () => ({
|
|
9871
|
+
nextRunAt,
|
|
9872
|
+
schedule: currentSchedule
|
|
9873
|
+
}),
|
|
9874
|
+
start: async () => {
|
|
9875
|
+
applySchedule(await getSchedule());
|
|
9876
|
+
},
|
|
9877
|
+
stop: () => {
|
|
9878
|
+
clearTimer();
|
|
9879
|
+
currentSchedule = "off";
|
|
9880
|
+
nextRunAt = null;
|
|
9881
|
+
},
|
|
9882
|
+
updateSchedule: (value) => {
|
|
9883
|
+
applySchedule(value);
|
|
9884
|
+
}
|
|
9885
|
+
};
|
|
9886
|
+
}
|
|
9887
|
+
const scheduler = createScheduler();
|
|
9888
|
+
//#endregion
|
|
9889
|
+
//#region src/api/routes/settings.ts
|
|
9890
|
+
const tmdbConfigurationUrl = "https://api.themoviedb.org/3/configuration";
|
|
9891
|
+
const reservedKeys = new Set([tmdbApiKeySettingKey, scanScheduleSettingKey]);
|
|
9892
|
+
const reservedKeyRoute = {
|
|
9893
|
+
[tmdbApiKeySettingKey]: "/api/settings/tmdb-key",
|
|
9894
|
+
[scanScheduleSettingKey]: "/api/settings/scan-schedule"
|
|
9895
|
+
};
|
|
9896
|
+
async function verifyTmdbKey(apiKey) {
|
|
9897
|
+
try {
|
|
9898
|
+
return (await fetch(`${tmdbConfigurationUrl}?api_key=${encodeURIComponent(apiKey)}`)).ok;
|
|
9899
|
+
} catch {
|
|
9900
|
+
return false;
|
|
9901
|
+
}
|
|
9902
|
+
}
|
|
9903
|
+
async function pathIsReadable(path) {
|
|
9904
|
+
try {
|
|
9905
|
+
await access(path, constants.R_OK);
|
|
9906
|
+
return true;
|
|
9907
|
+
} catch {
|
|
9908
|
+
return false;
|
|
9909
|
+
}
|
|
9910
|
+
}
|
|
9911
|
+
function validateLibraryInput(input) {
|
|
9912
|
+
if (typeof input !== "object" || input === null) return "Library entry must be an object.";
|
|
9913
|
+
const record = input;
|
|
9914
|
+
const name = typeof record.name === "string" ? record.name.trim() : "";
|
|
9915
|
+
const libraryPath = typeof record.path === "string" ? record.path.trim() : "";
|
|
9916
|
+
const type = record.type;
|
|
9917
|
+
if (libraryPath.length === 0) return "Library path is required.";
|
|
9918
|
+
if (type !== "movies" && type !== "shows") return "Library type must be \"movies\" or \"shows\".";
|
|
9919
|
+
return {
|
|
9920
|
+
name,
|
|
9921
|
+
path: libraryPath,
|
|
9922
|
+
type
|
|
9923
|
+
};
|
|
9924
|
+
}
|
|
9925
|
+
/**
|
|
9926
|
+
* Build the LIKE pattern that matches any file path under the given library
|
|
9927
|
+
* root. Escapes SQL LIKE wildcards so a path containing `%` or `_` doesn't
|
|
9928
|
+
* accidentally match siblings.
|
|
9929
|
+
*/
|
|
9930
|
+
function libraryPathPrefixPattern(libraryPath) {
|
|
9931
|
+
const resolved = path.resolve(libraryPath);
|
|
9932
|
+
return `${(resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`).replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`;
|
|
9933
|
+
}
|
|
9934
|
+
const settingsRoutes = new Hono();
|
|
9935
|
+
settingsRoutes.get("/tmdb-key", async (context) => {
|
|
9936
|
+
const apiKey = await getTmdbApiKey();
|
|
9937
|
+
return context.json({
|
|
9938
|
+
configured: apiKey !== null && apiKey.length > 0,
|
|
9939
|
+
apiKey: apiKey ?? ""
|
|
9940
|
+
});
|
|
9941
|
+
});
|
|
9942
|
+
settingsRoutes.put("/tmdb-key", async (context) => {
|
|
9943
|
+
let body;
|
|
9944
|
+
try {
|
|
9945
|
+
body = await context.req.json();
|
|
9946
|
+
} catch {
|
|
9947
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
9948
|
+
}
|
|
9949
|
+
const apiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
|
|
9950
|
+
if (apiKey.length === 0) return context.json({ error: { message: "TMDB API key is required. Get one at themoviedb.org/settings/api." } }, 400);
|
|
9951
|
+
if (!await verifyTmdbKey(apiKey)) return context.json({ error: { message: "Couldn't save TMDB key — the key wasn't accepted by TMDB. Check it at themoviedb.org/settings/api." } }, 400);
|
|
9952
|
+
await setTmdbApiKey(apiKey);
|
|
9953
|
+
return context.json({ configured: true });
|
|
9954
|
+
});
|
|
9955
|
+
settingsRoutes.get("/libraries", async (context) => {
|
|
9956
|
+
const libraries$2 = await db.select().from(libraries);
|
|
9957
|
+
return context.json(libraries$2.map((library) => ({
|
|
9958
|
+
id: library.id,
|
|
9959
|
+
name: library.name,
|
|
9960
|
+
path: library.path,
|
|
9961
|
+
type: library.type
|
|
9962
|
+
})));
|
|
9963
|
+
});
|
|
9964
|
+
settingsRoutes.post("/libraries", async (context) => {
|
|
9965
|
+
let body;
|
|
9966
|
+
try {
|
|
9967
|
+
body = await context.req.json();
|
|
9968
|
+
} catch {
|
|
9969
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
9970
|
+
}
|
|
9971
|
+
const parsed = validateLibraryInput(body);
|
|
9972
|
+
if (typeof parsed === "string") return context.json({ error: { message: parsed } }, 400);
|
|
9973
|
+
if (!await pathIsReadable(parsed.path)) return context.json({ error: { message: `Library path doesn't exist or isn't readable: ${parsed.path}. Check the path and Jukebox's file permissions.` } }, 400);
|
|
9974
|
+
const [existing] = await db.select().from(libraries).where(eq(libraries.path, parsed.path)).limit(1);
|
|
9975
|
+
if (existing) return context.json({ error: { message: `A library at ${parsed.path} already exists.` } }, 400);
|
|
9976
|
+
const resolvedName = parsed.name.length > 0 ? parsed.name : parsed.path.split(/[\\/]/).filter(Boolean).pop() ?? parsed.type;
|
|
9977
|
+
const [created] = await db.insert(libraries).values({
|
|
9978
|
+
name: resolvedName,
|
|
9979
|
+
path: parsed.path,
|
|
9980
|
+
type: parsed.type,
|
|
9981
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
9982
|
+
}).returning();
|
|
9983
|
+
if (!created) return context.json({ error: { message: "Failed to create library." } }, 500);
|
|
9984
|
+
return context.json({
|
|
9985
|
+
id: created.id,
|
|
9986
|
+
name: created.name,
|
|
9987
|
+
path: created.path,
|
|
9988
|
+
type: created.type
|
|
9989
|
+
}, 201);
|
|
9990
|
+
});
|
|
9991
|
+
settingsRoutes.delete("/libraries/:id", async (context) => {
|
|
9992
|
+
const id = Number.parseInt(context.req.param("id"), 10);
|
|
9993
|
+
if (!Number.isFinite(id)) return context.json({ error: { message: "Invalid library id." } }, 400);
|
|
9994
|
+
const [existing] = await db.select().from(libraries).where(eq(libraries.id, id)).limit(1);
|
|
9995
|
+
if (!existing) return context.json({ error: { message: "Library not found." } }, 404);
|
|
9996
|
+
const force = new URL(context.req.url).searchParams.get("force") === "true";
|
|
9997
|
+
const pattern = libraryPathPrefixPattern(existing.path);
|
|
9998
|
+
if (!force) {
|
|
9999
|
+
const referenceCount = countLibraryReferences(existing, pattern);
|
|
10000
|
+
if (referenceCount > 0) {
|
|
10001
|
+
const noun = existing.type === "movies" ? referenceCount === 1 ? "movie" : "movies" : referenceCount === 1 ? "show" : "shows";
|
|
10002
|
+
return context.json({ error: {
|
|
10003
|
+
message: `Couldn't remove library — ${referenceCount} ${noun} reference it. Remove them first or use 'Force remove'.`,
|
|
10004
|
+
referenceCount
|
|
10005
|
+
} }, 409);
|
|
10006
|
+
}
|
|
10007
|
+
db.delete(libraries).where(eq(libraries.id, id)).run();
|
|
10008
|
+
return context.json({ success: true });
|
|
10009
|
+
}
|
|
10010
|
+
db.transaction((tx) => {
|
|
10011
|
+
if (existing.type === "movies") {
|
|
10012
|
+
const movieIds = tx.select({ id: movies.id }).from(movies).where(sql`${movies.filePath} LIKE ${pattern} ESCAPE '\\'`).all().map((row) => row.id);
|
|
10013
|
+
if (movieIds.length > 0) {
|
|
10014
|
+
tx.delete(watchProgress).where(inArray(watchProgress.movieId, movieIds)).run();
|
|
10015
|
+
tx.delete(movies).where(inArray(movies.id, movieIds)).run();
|
|
10016
|
+
}
|
|
10017
|
+
} else {
|
|
10018
|
+
const showIds = tx.select({ id: shows.id }).from(shows).where(sql`${shows.folderPath} LIKE ${pattern} ESCAPE '\\'`).all().map((row) => row.id);
|
|
10019
|
+
if (showIds.length > 0) {
|
|
10020
|
+
const episodeIds = tx.select({ id: episodes.id }).from(episodes).where(inArray(episodes.showId, showIds)).all().map((row) => row.id);
|
|
10021
|
+
if (episodeIds.length > 0) {
|
|
10022
|
+
tx.delete(watchProgress).where(inArray(watchProgress.episodeId, episodeIds)).run();
|
|
10023
|
+
tx.delete(episodes).where(inArray(episodes.id, episodeIds)).run();
|
|
10024
|
+
}
|
|
10025
|
+
tx.delete(seasons).where(inArray(seasons.showId, showIds)).run();
|
|
10026
|
+
tx.delete(shows).where(inArray(shows.id, showIds)).run();
|
|
10027
|
+
}
|
|
10028
|
+
}
|
|
10029
|
+
tx.delete(libraries).where(eq(libraries.id, id)).run();
|
|
10030
|
+
});
|
|
10031
|
+
return context.json({ success: true });
|
|
10032
|
+
});
|
|
10033
|
+
function countLibraryReferences(library, pattern) {
|
|
10034
|
+
if (library.type === "movies") {
|
|
10035
|
+
const [row] = db.select({ count: sql`count(*)` }).from(movies).where(sql`${movies.filePath} LIKE ${pattern} ESCAPE '\\'`).all();
|
|
10036
|
+
return row?.count ?? 0;
|
|
10037
|
+
}
|
|
10038
|
+
const [row] = db.select({ count: sql`count(*)` }).from(shows).where(sql`${shows.folderPath} LIKE ${pattern} ESCAPE '\\'`).all();
|
|
10039
|
+
return row?.count ?? 0;
|
|
10040
|
+
}
|
|
10041
|
+
settingsRoutes.get("/scan-schedule", async (context) => {
|
|
10042
|
+
const stored = await getSetting(scanScheduleSettingKey);
|
|
10043
|
+
const value = stored !== null && isScanScheduleValue(stored) ? stored : "off";
|
|
10044
|
+
const info = scheduler.getInfo();
|
|
10045
|
+
return context.json({
|
|
10046
|
+
nextRunAt: info.nextRunAt?.toISOString() ?? null,
|
|
10047
|
+
schedule: value
|
|
10048
|
+
});
|
|
10049
|
+
});
|
|
10050
|
+
settingsRoutes.put("/scan-schedule", async (context) => {
|
|
10051
|
+
let body;
|
|
10052
|
+
try {
|
|
10053
|
+
body = await context.req.json();
|
|
10054
|
+
} catch {
|
|
10055
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
10056
|
+
}
|
|
10057
|
+
const schedule = typeof body.schedule === "string" ? body.schedule.trim() : "";
|
|
10058
|
+
if (!isScanScheduleValue(schedule)) return context.json({ error: { message: "Scan schedule must be one of: off, 6h, 12h, 24h." } }, 400);
|
|
10059
|
+
await setSetting(scanScheduleSettingKey, schedule);
|
|
10060
|
+
scheduler.updateSchedule(schedule);
|
|
10061
|
+
const info = scheduler.getInfo();
|
|
10062
|
+
return context.json({
|
|
10063
|
+
nextRunAt: info.nextRunAt?.toISOString() ?? null,
|
|
10064
|
+
schedule
|
|
10065
|
+
});
|
|
10066
|
+
});
|
|
10067
|
+
settingsRoutes.get("/:key", async (context) => {
|
|
10068
|
+
const key = context.req.param("key");
|
|
10069
|
+
if (reservedKeys.has(key)) return context.json({ error: { message: `Use GET ${reservedKeyRoute[key]} for this key.` } }, 400);
|
|
10070
|
+
const value = await getSetting(key);
|
|
10071
|
+
if (value === null) return context.json({ value: null });
|
|
10072
|
+
return context.json({ value });
|
|
10073
|
+
});
|
|
10074
|
+
settingsRoutes.put("/:key", async (context) => {
|
|
10075
|
+
const key = context.req.param("key");
|
|
10076
|
+
if (reservedKeys.has(key)) return context.json({ error: { message: `Use PUT ${reservedKeyRoute[key]} for this key.` } }, 400);
|
|
10077
|
+
let body;
|
|
10078
|
+
try {
|
|
10079
|
+
body = await context.req.json();
|
|
10080
|
+
} catch {
|
|
10081
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
10082
|
+
}
|
|
10083
|
+
if (typeof body.value !== "string") return context.json({ error: { message: "Setting value must be a string." } }, 400);
|
|
10084
|
+
await setSetting(key, body.value);
|
|
10085
|
+
return context.json({ value: body.value });
|
|
7817
10086
|
});
|
|
7818
10087
|
//#endregion
|
|
7819
10088
|
//#region src/api/routes/setup.ts
|
|
7820
10089
|
const setupRoutes = new Hono();
|
|
7821
10090
|
setupRoutes.get("/", async (context) => {
|
|
7822
|
-
const
|
|
10091
|
+
const apiKey = await getTmdbApiKey();
|
|
7823
10092
|
const libraries$1 = await db.select().from(libraries);
|
|
7824
10093
|
return context.json({
|
|
7825
|
-
config:
|
|
7826
|
-
hasApiKey:
|
|
10094
|
+
config: apiKey !== null ? { tmdbApiKey: apiKey } : null,
|
|
10095
|
+
hasApiKey: apiKey !== null && apiKey !== "",
|
|
7827
10096
|
libraries: libraries$1.map((library) => ({
|
|
7828
10097
|
id: library.id,
|
|
7829
10098
|
name: library.name,
|
|
@@ -7838,7 +10107,7 @@ setupRoutes.post("/complete", async (context) => {
|
|
|
7838
10107
|
const body = await context.req.json();
|
|
7839
10108
|
if (!body.tmdbApiKey) return context.json({ error: { message: "TMDB API key is required" } }, 400);
|
|
7840
10109
|
if (!body.libraries || body.libraries.length === 0) return context.json({ error: { message: "At least one library is required" } }, 400);
|
|
7841
|
-
await
|
|
10110
|
+
await setTmdbApiKey(body.tmdbApiKey);
|
|
7842
10111
|
await db.delete(libraries);
|
|
7843
10112
|
const now = /* @__PURE__ */ new Date();
|
|
7844
10113
|
for (const library of body.libraries) await db.insert(libraries).values({
|
|
@@ -7850,6 +10119,146 @@ setupRoutes.post("/complete", async (context) => {
|
|
|
7850
10119
|
return context.json({ success: true });
|
|
7851
10120
|
});
|
|
7852
10121
|
//#endregion
|
|
10122
|
+
//#region src/api/routes/profiles.ts
|
|
10123
|
+
const profileRoutes = new Hono();
|
|
10124
|
+
profileRoutes.get("/", async (context) => {
|
|
10125
|
+
const profiles$1 = await db.select().from(profiles).orderBy(desc(profiles.createdAt));
|
|
10126
|
+
return context.json(profiles$1);
|
|
10127
|
+
});
|
|
10128
|
+
profileRoutes.get("/active", async (context) => {
|
|
10129
|
+
const profileId = context.get("profileId");
|
|
10130
|
+
const [profile] = await db.select().from(profiles).where(eq(profiles.id, profileId)).limit(1);
|
|
10131
|
+
if (!profile) return context.json({ error: { message: "Active profile not found" } }, 404);
|
|
10132
|
+
return context.json(profile);
|
|
10133
|
+
});
|
|
10134
|
+
profileRoutes.post("/", async (context) => {
|
|
10135
|
+
const body = await context.req.json();
|
|
10136
|
+
const name = body.name?.trim();
|
|
10137
|
+
const emoji = body.emoji?.trim();
|
|
10138
|
+
if (!name || !emoji) return context.json({ error: { message: "name and emoji are required" } }, 400);
|
|
10139
|
+
const now = /* @__PURE__ */ new Date();
|
|
10140
|
+
try {
|
|
10141
|
+
const [created] = await db.insert(profiles).values({
|
|
10142
|
+
name,
|
|
10143
|
+
emoji,
|
|
10144
|
+
createdAt: now
|
|
10145
|
+
}).returning();
|
|
10146
|
+
return context.json(created, 201);
|
|
10147
|
+
} catch (error) {
|
|
10148
|
+
const message = error instanceof Error && error.message.includes("UNIQUE") ? "A profile with that name already exists" : "Failed to create profile";
|
|
10149
|
+
return context.json({ error: { message } }, 400);
|
|
10150
|
+
}
|
|
10151
|
+
});
|
|
10152
|
+
profileRoutes.patch("/:id", async (context) => {
|
|
10153
|
+
const id = parseInt(context.req.param("id"), 10);
|
|
10154
|
+
if (isNaN(id)) return context.json({ error: { message: "Invalid profile id" } }, 400);
|
|
10155
|
+
const body = await context.req.json();
|
|
10156
|
+
const updates = {};
|
|
10157
|
+
if (typeof body.name === "string" && body.name.trim()) updates.name = body.name.trim();
|
|
10158
|
+
if (typeof body.emoji === "string" && body.emoji.trim()) updates.emoji = body.emoji.trim();
|
|
10159
|
+
if (Object.keys(updates).length === 0) return context.json({ error: { message: "Nothing to update" } }, 400);
|
|
10160
|
+
try {
|
|
10161
|
+
const [updated] = await db.update(profiles).set(updates).where(eq(profiles.id, id)).returning();
|
|
10162
|
+
if (!updated) return context.json({ error: { message: "Profile not found" } }, 404);
|
|
10163
|
+
return context.json(updated);
|
|
10164
|
+
} catch (error) {
|
|
10165
|
+
const message = error instanceof Error && error.message.includes("UNIQUE") ? "A profile with that name already exists" : "Failed to update profile";
|
|
10166
|
+
return context.json({ error: { message } }, 400);
|
|
10167
|
+
}
|
|
10168
|
+
});
|
|
10169
|
+
profileRoutes.delete("/:id", async (context) => {
|
|
10170
|
+
const id = parseInt(context.req.param("id"), 10);
|
|
10171
|
+
if (isNaN(id)) return context.json({ error: { message: "Invalid profile id" } }, 400);
|
|
10172
|
+
const all = await db.select({ id: profiles.id }).from(profiles);
|
|
10173
|
+
if (all.length <= 1) return context.json({ error: { message: "Cannot delete the last remaining profile" } }, 400);
|
|
10174
|
+
await db.delete(profiles).where(eq(profiles.id, id));
|
|
10175
|
+
if (context.get("profileId") === id) {
|
|
10176
|
+
const next = all.find((profile) => profile.id !== id);
|
|
10177
|
+
if (next) setProfileCookie(context, next.id);
|
|
10178
|
+
}
|
|
10179
|
+
return context.json({ success: true });
|
|
10180
|
+
});
|
|
10181
|
+
profileRoutes.post("/:id/activate", async (context) => {
|
|
10182
|
+
const id = parseInt(context.req.param("id"), 10);
|
|
10183
|
+
if (isNaN(id)) return context.json({ error: { message: "Invalid profile id" } }, 400);
|
|
10184
|
+
const [profile] = await db.select().from(profiles).where(eq(profiles.id, id)).limit(1);
|
|
10185
|
+
if (!profile) return context.json({ error: { message: "Profile not found" } }, 404);
|
|
10186
|
+
setProfileCookie(context, profile.id);
|
|
10187
|
+
return context.json(profile);
|
|
10188
|
+
});
|
|
10189
|
+
//#endregion
|
|
10190
|
+
//#region src/api/routes/favorites.ts
|
|
10191
|
+
const favoriteRoutes = new Hono();
|
|
10192
|
+
favoriteRoutes.get("/", async (context) => {
|
|
10193
|
+
const profileId = context.get("profileId");
|
|
10194
|
+
const movieFavorites = await db.select({
|
|
10195
|
+
createdAt: favorites.createdAt,
|
|
10196
|
+
movie: movies
|
|
10197
|
+
}).from(favorites).innerJoin(movies, eq(favorites.movieId, movies.id)).where(eq(favorites.profileId, profileId)).orderBy(desc(favorites.createdAt));
|
|
10198
|
+
const showFavorites = await db.select({
|
|
10199
|
+
createdAt: favorites.createdAt,
|
|
10200
|
+
show: shows
|
|
10201
|
+
}).from(favorites).innerJoin(shows, eq(favorites.showId, shows.id)).where(eq(favorites.profileId, profileId)).orderBy(desc(favorites.createdAt));
|
|
10202
|
+
const movies$1 = movieFavorites.map((row) => ({
|
|
10203
|
+
...row,
|
|
10204
|
+
type: "movie"
|
|
10205
|
+
}));
|
|
10206
|
+
const shows$2 = showFavorites.map((row) => ({
|
|
10207
|
+
...row,
|
|
10208
|
+
type: "show"
|
|
10209
|
+
}));
|
|
10210
|
+
const combined = [...movies$1, ...shows$2].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
10211
|
+
return context.json(combined);
|
|
10212
|
+
});
|
|
10213
|
+
favoriteRoutes.get("/status", async (context) => {
|
|
10214
|
+
const profileId = context.get("profileId");
|
|
10215
|
+
const movieIdParam = context.req.query("movieId");
|
|
10216
|
+
const showIdParam = context.req.query("showId");
|
|
10217
|
+
if (movieIdParam) {
|
|
10218
|
+
const movieId = parseInt(movieIdParam, 10);
|
|
10219
|
+
if (isNaN(movieId)) return context.json({ error: { message: "Invalid movie ID" } }, 400);
|
|
10220
|
+
const [favorite] = await db.select({ id: favorites.id }).from(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.movieId, movieId))).limit(1);
|
|
10221
|
+
return context.json({ favorite: !!favorite });
|
|
10222
|
+
}
|
|
10223
|
+
if (showIdParam) {
|
|
10224
|
+
const showId = parseInt(showIdParam, 10);
|
|
10225
|
+
if (isNaN(showId)) return context.json({ error: { message: "Invalid show ID" } }, 400);
|
|
10226
|
+
const [favorite] = await db.select({ id: favorites.id }).from(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.showId, showId))).limit(1);
|
|
10227
|
+
return context.json({ favorite: !!favorite });
|
|
10228
|
+
}
|
|
10229
|
+
return context.json({ error: { message: "movieId or showId required" } }, 400);
|
|
10230
|
+
});
|
|
10231
|
+
favoriteRoutes.post("/", async (context) => {
|
|
10232
|
+
const profileId = context.get("profileId");
|
|
10233
|
+
const body = await context.req.json();
|
|
10234
|
+
if (typeof body.movieId !== "number" && typeof body.showId !== "number" || typeof body.movieId === "number" && typeof body.showId === "number") return context.json({ error: { message: "Provide exactly one of movieId or showId" } }, 400);
|
|
10235
|
+
const now = /* @__PURE__ */ new Date();
|
|
10236
|
+
try {
|
|
10237
|
+
await db.insert(favorites).values({
|
|
10238
|
+
profileId,
|
|
10239
|
+
movieId: body.movieId ?? null,
|
|
10240
|
+
showId: body.showId ?? null,
|
|
10241
|
+
createdAt: now
|
|
10242
|
+
});
|
|
10243
|
+
} catch (error) {
|
|
10244
|
+
if (!(error instanceof Error) || !error.message.includes("UNIQUE")) throw error;
|
|
10245
|
+
}
|
|
10246
|
+
return context.json({ success: true });
|
|
10247
|
+
});
|
|
10248
|
+
favoriteRoutes.delete("/", async (context) => {
|
|
10249
|
+
const profileId = context.get("profileId");
|
|
10250
|
+
const body = await context.req.json();
|
|
10251
|
+
if (typeof body.movieId === "number") {
|
|
10252
|
+
await db.delete(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.movieId, body.movieId)));
|
|
10253
|
+
return context.json({ success: true });
|
|
10254
|
+
}
|
|
10255
|
+
if (typeof body.showId === "number") {
|
|
10256
|
+
await db.delete(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.showId, body.showId)));
|
|
10257
|
+
return context.json({ success: true });
|
|
10258
|
+
}
|
|
10259
|
+
return context.json({ error: { message: "movieId or showId required" } }, 400);
|
|
10260
|
+
});
|
|
10261
|
+
//#endregion
|
|
7853
10262
|
//#region src/api/routes/shows.ts
|
|
7854
10263
|
const showRoutes = new Hono();
|
|
7855
10264
|
showRoutes.get("/", async (context) => {
|
|
@@ -7871,8 +10280,35 @@ showRoutes.get("/episodes/:id", async (context) => {
|
|
|
7871
10280
|
const [episode] = await db.select().from(episodes).where(eq(episodes.id, id)).limit(1);
|
|
7872
10281
|
if (!episode) return context.json({ error: { message: "Episode not found" } }, 404);
|
|
7873
10282
|
const [show] = await db.select().from(shows).where(eq(shows.id, episode.showId)).limit(1);
|
|
10283
|
+
const subtitles$1 = (await db.select().from(subtitles).where(eq(subtitles.episodeId, id))).map((row) => ({
|
|
10284
|
+
id: row.id,
|
|
10285
|
+
displayLanguage: languageDisplayName(row.language),
|
|
10286
|
+
format: row.format,
|
|
10287
|
+
isSupported: row.format !== "ass",
|
|
10288
|
+
language: row.language
|
|
10289
|
+
}));
|
|
7874
10290
|
return context.json({
|
|
7875
10291
|
episode,
|
|
10292
|
+
show,
|
|
10293
|
+
subtitles: subtitles$1
|
|
10294
|
+
});
|
|
10295
|
+
});
|
|
10296
|
+
showRoutes.get("/:showId/next-episode", async (context) => {
|
|
10297
|
+
const showId = parseInt(context.req.param("showId"), 10);
|
|
10298
|
+
const afterEpisodeIdParam = context.req.query("afterEpisodeId");
|
|
10299
|
+
const afterEpisodeId = afterEpisodeIdParam ? parseInt(afterEpisodeIdParam, 10) : NaN;
|
|
10300
|
+
if (isNaN(showId) || isNaN(afterEpisodeId)) return context.json({ error: { message: "Provide a numeric showId and a numeric afterEpisodeId query parameter." } }, 400);
|
|
10301
|
+
const [currentEpisode] = await db.select().from(episodes).where(and(eq(episodes.id, afterEpisodeId), eq(episodes.showId, showId))).limit(1);
|
|
10302
|
+
if (!currentEpisode) return context.json({ error: { message: "That episode does not belong to this show. Double-check the showId and afterEpisodeId." } }, 404);
|
|
10303
|
+
const nextEpisode = (await db.select().from(episodes).where(eq(episodes.showId, showId)).orderBy(episodes.seasonNumber, episodes.episodeNumber)).find((candidate) => {
|
|
10304
|
+
if (candidate.seasonNumber > currentEpisode.seasonNumber) return true;
|
|
10305
|
+
if (candidate.seasonNumber < currentEpisode.seasonNumber) return false;
|
|
10306
|
+
return candidate.episodeNumber > currentEpisode.episodeNumber;
|
|
10307
|
+
});
|
|
10308
|
+
if (!nextEpisode) return context.json({ error: { message: "No more episodes after this one." } }, 404);
|
|
10309
|
+
const [show] = await db.select().from(shows).where(eq(shows.id, showId)).limit(1);
|
|
10310
|
+
return context.json({
|
|
10311
|
+
episode: nextEpisode,
|
|
7876
10312
|
show
|
|
7877
10313
|
});
|
|
7878
10314
|
});
|
|
@@ -7952,23 +10388,341 @@ streamRoutes.get("/:id", async (context) => {
|
|
|
7952
10388
|
} });
|
|
7953
10389
|
});
|
|
7954
10390
|
//#endregion
|
|
10391
|
+
//#region src/api/routes/subtitles.ts
|
|
10392
|
+
const subtitleRoutes = new Hono();
|
|
10393
|
+
const conversionFailedMessage = "Couldn't convert subtitle file. Check it's a valid SRT.";
|
|
10394
|
+
const outsideLibraryMessage = "Subtitle is outside the configured library paths. Rescan your libraries.";
|
|
10395
|
+
function subtitleIsInsideALibrary(subtitleFilePath, libraryPaths) {
|
|
10396
|
+
const resolvedSubtitle = path.resolve(subtitleFilePath);
|
|
10397
|
+
for (const libraryPath of libraryPaths) {
|
|
10398
|
+
const resolvedLibrary = path.resolve(libraryPath);
|
|
10399
|
+
const libraryWithSeparator = resolvedLibrary.endsWith(path.sep) ? resolvedLibrary : `${resolvedLibrary}${path.sep}`;
|
|
10400
|
+
if (resolvedSubtitle.startsWith(libraryWithSeparator)) return true;
|
|
10401
|
+
}
|
|
10402
|
+
return false;
|
|
10403
|
+
}
|
|
10404
|
+
subtitleRoutes.get("/:id", async (context) => {
|
|
10405
|
+
const id = parseInt(context.req.param("id"), 10);
|
|
10406
|
+
if (isNaN(id)) return context.json({ error: { message: "Invalid subtitle ID" } }, 400);
|
|
10407
|
+
const [subtitle] = await db.select().from(subtitles).where(eq(subtitles.id, id)).limit(1);
|
|
10408
|
+
if (!subtitle) return context.json({ error: { message: "Subtitle not found" } }, 404);
|
|
10409
|
+
const libraryPaths = (await db.select({ path: libraries.path }).from(libraries)).map((library) => library.path);
|
|
10410
|
+
if (!subtitleIsInsideALibrary(subtitle.filePath, libraryPaths)) {
|
|
10411
|
+
log.warn(`Refusing to serve subtitle ${subtitle.id} — ${subtitle.filePath} is not inside any configured library. Rescan the library to clean up stale rows.`);
|
|
10412
|
+
return context.json({ error: { message: outsideLibraryMessage } }, 404);
|
|
10413
|
+
}
|
|
10414
|
+
if (subtitle.format === "ass") return context.text("ASS subtitles aren't supported by the web player. Convert the file to .srt or .vtt and rescan.", 415);
|
|
10415
|
+
let raw;
|
|
10416
|
+
try {
|
|
10417
|
+
raw = await readFile(subtitle.filePath, "utf-8");
|
|
10418
|
+
} catch (error) {
|
|
10419
|
+
log.warn(`Couldn't read subtitle file ${subtitle.filePath} - ${error instanceof Error ? error.message : String(error)}. Make sure the file still exists at that path and rescan the library.`);
|
|
10420
|
+
return context.text(conversionFailedMessage, 500);
|
|
10421
|
+
}
|
|
10422
|
+
if (subtitle.format === "vtt") return new Response(raw, {
|
|
10423
|
+
status: 200,
|
|
10424
|
+
headers: {
|
|
10425
|
+
"Cache-Control": "public, max-age=3600",
|
|
10426
|
+
"Content-Type": "text/vtt; charset=utf-8"
|
|
10427
|
+
}
|
|
10428
|
+
});
|
|
10429
|
+
let converted;
|
|
10430
|
+
try {
|
|
10431
|
+
converted = convertSrtToVtt(raw);
|
|
10432
|
+
} catch (error) {
|
|
10433
|
+
log.warn(`Couldn't convert subtitle file ${subtitle.filePath} to WebVTT - ${error instanceof Error ? error.message : String(error)}. Convert the file to UTF-8 .vtt and rescan.`);
|
|
10434
|
+
return context.text(conversionFailedMessage, 500);
|
|
10435
|
+
}
|
|
10436
|
+
return new Response(converted, {
|
|
10437
|
+
status: 200,
|
|
10438
|
+
headers: {
|
|
10439
|
+
"Cache-Control": "public, max-age=3600",
|
|
10440
|
+
"Content-Type": "text/vtt; charset=utf-8"
|
|
10441
|
+
}
|
|
10442
|
+
});
|
|
10443
|
+
});
|
|
10444
|
+
//#endregion
|
|
10445
|
+
//#region src/api/routes/transcode.ts
|
|
10446
|
+
const logger = {
|
|
10447
|
+
error: (...args) => console.error("[transcode]", ...args),
|
|
10448
|
+
info: (...args) => console.info("[transcode]", ...args),
|
|
10449
|
+
warn: (...args) => console.warn("[transcode]", ...args)
|
|
10450
|
+
};
|
|
10451
|
+
const reapIdleMs = 1800 * 1e3;
|
|
10452
|
+
const reaperIntervalMs = 300 * 1e3;
|
|
10453
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
10454
|
+
function sessionKey(fileId, profileId) {
|
|
10455
|
+
return `${fileId}:${profileId}`;
|
|
10456
|
+
}
|
|
10457
|
+
function safeDirectoryName(fileId, profileId) {
|
|
10458
|
+
return `${fileId}__${profileId}`;
|
|
10459
|
+
}
|
|
10460
|
+
function getTranscodeRoot() {
|
|
10461
|
+
return path.join(os.tmpdir(), "jukebox-transcode");
|
|
10462
|
+
}
|
|
10463
|
+
function waitForPlaylist(playlistPath, timeoutMs) {
|
|
10464
|
+
return new Promise((resolve, reject) => {
|
|
10465
|
+
const startedAt = Date.now();
|
|
10466
|
+
const check = () => {
|
|
10467
|
+
if (existsSync(playlistPath)) {
|
|
10468
|
+
resolve();
|
|
10469
|
+
return;
|
|
10470
|
+
}
|
|
10471
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
10472
|
+
reject(/* @__PURE__ */ new Error("Couldn't prepare this file for casting. Check that ffmpeg is installed."));
|
|
10473
|
+
return;
|
|
10474
|
+
}
|
|
10475
|
+
setTimeout(check, 200);
|
|
10476
|
+
};
|
|
10477
|
+
check();
|
|
10478
|
+
});
|
|
10479
|
+
}
|
|
10480
|
+
function startTranscode({ fileId, filePath, profileId, spawnImplementation }) {
|
|
10481
|
+
const key = sessionKey(fileId, profileId);
|
|
10482
|
+
const existing = sessions.get(key);
|
|
10483
|
+
if (existing) {
|
|
10484
|
+
existing.lastAccessedAt = Date.now();
|
|
10485
|
+
return existing;
|
|
10486
|
+
}
|
|
10487
|
+
const tempDir = path.join(getTranscodeRoot(), safeDirectoryName(fileId, profileId));
|
|
10488
|
+
mkdirSync(tempDir, { recursive: true });
|
|
10489
|
+
const playlistPath = path.join(tempDir, "index.m3u8");
|
|
10490
|
+
const args = [
|
|
10491
|
+
"-i",
|
|
10492
|
+
filePath,
|
|
10493
|
+
"-c:v",
|
|
10494
|
+
"copy",
|
|
10495
|
+
"-c:a",
|
|
10496
|
+
"aac",
|
|
10497
|
+
"-ac",
|
|
10498
|
+
"2",
|
|
10499
|
+
"-f",
|
|
10500
|
+
"hls",
|
|
10501
|
+
"-hls_time",
|
|
10502
|
+
"6",
|
|
10503
|
+
"-hls_list_size",
|
|
10504
|
+
"0",
|
|
10505
|
+
"-hls_segment_filename",
|
|
10506
|
+
path.join(tempDir, "segment-%03d.ts"),
|
|
10507
|
+
playlistPath
|
|
10508
|
+
];
|
|
10509
|
+
const spawner = spawnImplementation ?? spawn;
|
|
10510
|
+
let ffmpegProcess;
|
|
10511
|
+
try {
|
|
10512
|
+
ffmpegProcess = spawner("ffmpeg", args, { stdio: [
|
|
10513
|
+
"ignore",
|
|
10514
|
+
"pipe",
|
|
10515
|
+
"pipe"
|
|
10516
|
+
] });
|
|
10517
|
+
} catch (error) {
|
|
10518
|
+
logger.error(`Failed to spawn ffmpeg for ${fileId}:`, error);
|
|
10519
|
+
throw new Error("Couldn't prepare this file for casting. Check that ffmpeg is installed.", { cause: error });
|
|
10520
|
+
}
|
|
10521
|
+
ffmpegProcess.on("error", (error) => {
|
|
10522
|
+
logger.error(`ffmpeg process error for ${fileId}:`, error);
|
|
10523
|
+
});
|
|
10524
|
+
ffmpegProcess.on("exit", (code) => {
|
|
10525
|
+
logger.info(`ffmpeg exited for ${fileId} with code ${code}`);
|
|
10526
|
+
});
|
|
10527
|
+
const readyPromise = waitForPlaylist(playlistPath, 3e4);
|
|
10528
|
+
const session = {
|
|
10529
|
+
fileId,
|
|
10530
|
+
ffmpegProcess,
|
|
10531
|
+
lastAccessedAt: Date.now(),
|
|
10532
|
+
playlistPath,
|
|
10533
|
+
tempDir,
|
|
10534
|
+
readyPromise
|
|
10535
|
+
};
|
|
10536
|
+
sessions.set(key, session);
|
|
10537
|
+
return session;
|
|
10538
|
+
}
|
|
10539
|
+
function stopSession(key) {
|
|
10540
|
+
const session = sessions.get(key);
|
|
10541
|
+
if (!session) return;
|
|
10542
|
+
if (session.ffmpegProcess && session.ffmpegProcess.exitCode === null) try {
|
|
10543
|
+
session.ffmpegProcess.kill("SIGTERM");
|
|
10544
|
+
} catch (error) {
|
|
10545
|
+
logger.warn(`Failed to kill ffmpeg for ${session.fileId}:`, error);
|
|
10546
|
+
}
|
|
10547
|
+
try {
|
|
10548
|
+
rmSync(session.tempDir, {
|
|
10549
|
+
recursive: true,
|
|
10550
|
+
force: true
|
|
10551
|
+
});
|
|
10552
|
+
} catch (error) {
|
|
10553
|
+
logger.warn(`Failed to remove temp dir ${session.tempDir}:`, error);
|
|
10554
|
+
}
|
|
10555
|
+
sessions.delete(key);
|
|
10556
|
+
}
|
|
10557
|
+
function reapIdleSessions(now = Date.now()) {
|
|
10558
|
+
for (const [key, session] of sessions.entries()) if (now - session.lastAccessedAt > reapIdleMs) {
|
|
10559
|
+
logger.info(`Reaping idle transcode session ${key}`);
|
|
10560
|
+
stopSession(key);
|
|
10561
|
+
}
|
|
10562
|
+
}
|
|
10563
|
+
if (typeof setInterval !== "undefined" && process.env.NODE_ENV !== "test") setInterval(() => reapIdleSessions(), reaperIntervalMs).unref?.();
|
|
10564
|
+
async function resolveFile(fileId) {
|
|
10565
|
+
if (fileId.startsWith("episode-")) {
|
|
10566
|
+
const episodeId = parseInt(fileId.slice(8), 10);
|
|
10567
|
+
if (isNaN(episodeId)) return null;
|
|
10568
|
+
const [episode] = await db.select().from(episodes).where(eq(episodes.id, episodeId)).limit(1);
|
|
10569
|
+
if (!episode) return null;
|
|
10570
|
+
return { filePath: episode.filePath };
|
|
10571
|
+
}
|
|
10572
|
+
if (fileId.startsWith("movie-")) {
|
|
10573
|
+
const movieId = parseInt(fileId.slice(6), 10);
|
|
10574
|
+
if (isNaN(movieId)) return null;
|
|
10575
|
+
const [movie] = await db.select().from(movies).where(eq(movies.id, movieId)).limit(1);
|
|
10576
|
+
if (!movie) return null;
|
|
10577
|
+
return { filePath: movie.filePath };
|
|
10578
|
+
}
|
|
10579
|
+
return null;
|
|
10580
|
+
}
|
|
10581
|
+
const transcodeRoutes = new Hono();
|
|
10582
|
+
transcodeRoutes.get("/:fileId/index.m3u8", async (context) => {
|
|
10583
|
+
const fileId = context.req.param("fileId");
|
|
10584
|
+
const profileIdRaw = context.req.header("x-jukebox-profile-id") ?? "0";
|
|
10585
|
+
const profileId = parseInt(profileIdRaw, 10);
|
|
10586
|
+
const resolved = await resolveFile(fileId);
|
|
10587
|
+
if (!resolved) return context.json({ error: { message: "File not found" } }, 404);
|
|
10588
|
+
if (!existsSync(resolved.filePath)) return context.json({ error: { message: "Video file not found" } }, 404);
|
|
10589
|
+
try {
|
|
10590
|
+
const session = startTranscode({
|
|
10591
|
+
fileId,
|
|
10592
|
+
filePath: resolved.filePath,
|
|
10593
|
+
profileId: isNaN(profileId) ? 0 : profileId
|
|
10594
|
+
});
|
|
10595
|
+
session.lastAccessedAt = Date.now();
|
|
10596
|
+
await session.readyPromise;
|
|
10597
|
+
const playlistStream = createReadStream(session.playlistPath);
|
|
10598
|
+
const webStream = Readable.toWeb(playlistStream);
|
|
10599
|
+
return new Response(webStream, { headers: {
|
|
10600
|
+
"Content-Type": "application/vnd.apple.mpegurl",
|
|
10601
|
+
"Cache-Control": "no-store"
|
|
10602
|
+
} });
|
|
10603
|
+
} catch (error) {
|
|
10604
|
+
const message = error instanceof Error ? error.message : "Couldn't prepare this file for casting. Check that ffmpeg is installed.";
|
|
10605
|
+
return context.json({ error: { message } }, 500);
|
|
10606
|
+
}
|
|
10607
|
+
});
|
|
10608
|
+
transcodeRoutes.get("/:fileId/:segment", async (context) => {
|
|
10609
|
+
const fileId = context.req.param("fileId");
|
|
10610
|
+
const segment = context.req.param("segment");
|
|
10611
|
+
const profileIdRaw = context.req.header("x-jukebox-profile-id") ?? "0";
|
|
10612
|
+
const profileId = parseInt(profileIdRaw, 10);
|
|
10613
|
+
const key = sessionKey(fileId, isNaN(profileId) ? 0 : profileId);
|
|
10614
|
+
const session = sessions.get(key);
|
|
10615
|
+
if (!session) return context.json({ error: { message: "Transcode session not found" } }, 404);
|
|
10616
|
+
if (!/^segment-\d+\.ts$/.test(segment)) return context.json({ error: { message: "Invalid segment" } }, 400);
|
|
10617
|
+
const segmentPath = path.join(session.tempDir, segment);
|
|
10618
|
+
const startedAt = Date.now();
|
|
10619
|
+
while (!existsSync(segmentPath) && Date.now() - startedAt < 15e3) await new Promise((resolve) => setTimeout(resolve, 200));
|
|
10620
|
+
if (!existsSync(segmentPath)) return context.json({ error: { message: "Segment not ready" } }, 404);
|
|
10621
|
+
session.lastAccessedAt = Date.now();
|
|
10622
|
+
const size = statSync(segmentPath).size;
|
|
10623
|
+
const nodeStream = createReadStream(segmentPath);
|
|
10624
|
+
const webStream = Readable.toWeb(nodeStream);
|
|
10625
|
+
return new Response(webStream, { headers: {
|
|
10626
|
+
"Content-Type": "video/mp2t",
|
|
10627
|
+
"Content-Length": String(size),
|
|
10628
|
+
"Cache-Control": "no-store"
|
|
10629
|
+
} });
|
|
10630
|
+
});
|
|
10631
|
+
try {
|
|
10632
|
+
const root = getTranscodeRoot();
|
|
10633
|
+
if (existsSync(root)) for (const entry of readdirSync(root)) rmSync(path.join(root, entry), {
|
|
10634
|
+
recursive: true,
|
|
10635
|
+
force: true
|
|
10636
|
+
});
|
|
10637
|
+
} catch (error) {
|
|
10638
|
+
logger.warn("Failed to clear transcode root on boot:", error);
|
|
10639
|
+
}
|
|
10640
|
+
//#endregion
|
|
10641
|
+
//#region src/lib/watched.ts
|
|
10642
|
+
/**
|
|
10643
|
+
* Shared "watched" threshold used across the server and client.
|
|
10644
|
+
*
|
|
10645
|
+
* An episode (or movie) is considered complete once the profile has
|
|
10646
|
+
* progressed at least this fraction of the total duration. Kept in a single
|
|
10647
|
+
* module so server endpoints ("Up Next", next-episode) and client overlays
|
|
10648
|
+
* ("Up Next" countdown) can't drift.
|
|
10649
|
+
*/
|
|
10650
|
+
const watchedThreshold = .9;
|
|
10651
|
+
//#endregion
|
|
10652
|
+
//#region src/api/routes/up-next.ts
|
|
10653
|
+
const upNextRoutes = new Hono();
|
|
10654
|
+
upNextRoutes.get("/", async (context) => {
|
|
10655
|
+
const profileId = context.get("profileId");
|
|
10656
|
+
const episodeProgressRows = await db.select({
|
|
10657
|
+
currentTime: watchProgress.currentTime,
|
|
10658
|
+
duration: watchProgress.duration,
|
|
10659
|
+
episode: episodes,
|
|
10660
|
+
updatedAt: watchProgress.updatedAt
|
|
10661
|
+
}).from(watchProgress).innerJoin(episodes, eq(watchProgress.episodeId, episodes.id)).where(eq(watchProgress.profileId, profileId)).orderBy(desc(watchProgress.updatedAt));
|
|
10662
|
+
const latestRowByShow = /* @__PURE__ */ new Map();
|
|
10663
|
+
for (const row of episodeProgressRows) if (!latestRowByShow.has(row.episode.showId)) latestRowByShow.set(row.episode.showId, row);
|
|
10664
|
+
const items = [];
|
|
10665
|
+
for (const [showId, row] of latestRowByShow.entries()) {
|
|
10666
|
+
if (!((row.duration && row.duration > 0 ? row.currentTime / row.duration : 0) >= .9)) continue;
|
|
10667
|
+
const laterEpisodes = (await db.select().from(episodes).where(eq(episodes.showId, showId)).orderBy(episodes.seasonNumber, episodes.episodeNumber)).filter((candidate) => {
|
|
10668
|
+
if (candidate.seasonNumber > row.episode.seasonNumber) return true;
|
|
10669
|
+
if (candidate.seasonNumber < row.episode.seasonNumber) return false;
|
|
10670
|
+
return candidate.episodeNumber > row.episode.episodeNumber;
|
|
10671
|
+
});
|
|
10672
|
+
if (laterEpisodes.length === 0) continue;
|
|
10673
|
+
const laterIds = new Set(laterEpisodes.map((candidate) => candidate.id));
|
|
10674
|
+
const laterProgress = episodeProgressRows.filter((progressRow) => progressRow.episode.id !== row.episode.id && laterIds.has(progressRow.episode.id));
|
|
10675
|
+
const progressByEpisodeId = /* @__PURE__ */ new Map();
|
|
10676
|
+
for (const progressRow of laterProgress) progressByEpisodeId.set(progressRow.episode.id, {
|
|
10677
|
+
currentTime: progressRow.currentTime,
|
|
10678
|
+
duration: progressRow.duration
|
|
10679
|
+
});
|
|
10680
|
+
const nextEpisode = laterEpisodes.find((candidate) => {
|
|
10681
|
+
const progress = progressByEpisodeId.get(candidate.id);
|
|
10682
|
+
if (!progress) return true;
|
|
10683
|
+
if (!progress.duration || progress.duration <= 0) return true;
|
|
10684
|
+
return progress.currentTime / progress.duration < watchedThreshold;
|
|
10685
|
+
});
|
|
10686
|
+
if (!nextEpisode) continue;
|
|
10687
|
+
const [show] = await db.select().from(shows).where(eq(shows.id, showId)).limit(1);
|
|
10688
|
+
if (!show) continue;
|
|
10689
|
+
items.push({
|
|
10690
|
+
episode: nextEpisode,
|
|
10691
|
+
show,
|
|
10692
|
+
lastWatchedAt: row.updatedAt.toISOString()
|
|
10693
|
+
});
|
|
10694
|
+
}
|
|
10695
|
+
items.sort((a, b) => new Date(b.lastWatchedAt).getTime() - new Date(a.lastWatchedAt).getTime());
|
|
10696
|
+
return context.json(items.slice(0, 20));
|
|
10697
|
+
});
|
|
10698
|
+
//#endregion
|
|
7955
10699
|
//#region src/api/index.ts
|
|
7956
10700
|
const app = new Hono();
|
|
7957
|
-
app.use("*", logger());
|
|
10701
|
+
app.use("*", logger$1());
|
|
10702
|
+
app.use("/api/*", authMiddleware);
|
|
10703
|
+
app.use("/api/*", profileMiddleware);
|
|
7958
10704
|
app.onError((error, context) => {
|
|
7959
10705
|
console.error("Unhandled error:", error);
|
|
7960
10706
|
const message = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
7961
10707
|
return context.json({ error: { message } }, 500);
|
|
7962
10708
|
});
|
|
10709
|
+
app.route("/api/auth", authRoutes);
|
|
7963
10710
|
app.route("/api/hello", helloRoutes);
|
|
7964
10711
|
app.route("/api/library/shows", showRoutes);
|
|
10712
|
+
app.route("/api/library/up-next", upNextRoutes);
|
|
7965
10713
|
app.route("/api/library", libraryRoutes);
|
|
7966
10714
|
app.route("/api/progress/episode", episodeProgressRoutes);
|
|
7967
10715
|
app.route("/api/progress", progressRoutes);
|
|
7968
10716
|
app.route("/api/scan", scanRoutes);
|
|
10717
|
+
app.route("/api/search", searchRoutes);
|
|
10718
|
+
app.route("/api/profiles", profileRoutes);
|
|
10719
|
+
app.route("/api/favorites", favoriteRoutes);
|
|
10720
|
+
app.route("/api/settings", settingsRoutes);
|
|
7969
10721
|
app.route("/api/setup", setupRoutes);
|
|
7970
10722
|
app.route("/api/stream/episode", episodeStreamRoutes);
|
|
7971
10723
|
app.route("/api/stream", streamRoutes);
|
|
10724
|
+
app.route("/api/subtitles", subtitleRoutes);
|
|
10725
|
+
app.route("/api/transcode", transcodeRoutes);
|
|
7972
10726
|
app.get("/api", (c) => c.json({ message: "Jukebox API" }));
|
|
7973
10727
|
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
|
|
7974
10728
|
const clientDir = path.resolve(__dirname$1, "../client");
|
|
@@ -8035,7 +10789,8 @@ function readPackageVersion() {
|
|
|
8035
10789
|
const version = readPackageVersion();
|
|
8036
10790
|
let viteServer = null;
|
|
8037
10791
|
if (isDevelopment) {
|
|
8038
|
-
|
|
10792
|
+
const { createServer } = await import("vite");
|
|
10793
|
+
viteServer = await createServer({
|
|
8039
10794
|
configFile: "./vite.config.ts",
|
|
8040
10795
|
server: {
|
|
8041
10796
|
port: vitePort,
|
|
@@ -8045,6 +10800,8 @@ if (isDevelopment) {
|
|
|
8045
10800
|
await viteServer.listen();
|
|
8046
10801
|
setupViteProxy(vitePort);
|
|
8047
10802
|
} else setupStaticServing();
|
|
10803
|
+
await scanManager.recoverInterruptedJobs();
|
|
10804
|
+
await scheduler.start();
|
|
8048
10805
|
const server = serve({
|
|
8049
10806
|
fetch: app.fetch,
|
|
8050
10807
|
port
|
|
@@ -8070,6 +10827,7 @@ function printWelcome(boundPort) {
|
|
|
8070
10827
|
function shutdown(signal) {
|
|
8071
10828
|
console.log(`\nReceived ${signal}, shutting down...`);
|
|
8072
10829
|
setTimeout(() => process.exit(1), 2e3).unref();
|
|
10830
|
+
scheduler.stop();
|
|
8073
10831
|
server.close(() => {
|
|
8074
10832
|
if (viteServer) viteServer.close().finally(() => process.exit(0));
|
|
8075
10833
|
else process.exit(0);
|