jukebox-media-server 0.2.0 → 0.4.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 +2 -2
- package/dist/client/assets/{Watch-kJkUCvFd.js → Watch-DE2orFm6.js} +32 -32
- package/dist/client/assets/index-CLZKF1oG.css +1 -0
- package/dist/client/assets/index-hOWDqZr2.js +58 -0
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/index.js +1944 -379
- 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/0010_metadata_rename.sql +49 -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/0010_snapshot.json +1059 -0
- package/drizzle/meta/_journal.json +43 -1
- package/package.json +2 -1
- package/dist/client/assets/index-1v1BzSVv.js +0 -56
- package/dist/client/assets/index-FPcl2l8F.css +0 -1
package/dist/server/index.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, 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
7
|
import crypto$1, { randomBytes, scrypt, timingSafeEqual } from "crypto";
|
|
8
8
|
import { versions } from "process";
|
|
9
9
|
import Database from "better-sqlite3";
|
|
10
10
|
import crypto$2 from "node:crypto";
|
|
11
11
|
import fs from "node:fs";
|
|
12
|
-
import { readdir, stat, writeFile } from "fs/promises";
|
|
13
12
|
import os, { homedir } from "os";
|
|
14
13
|
import { promisify } from "util";
|
|
14
|
+
import { access, readFile, readdir, stat } from "fs/promises";
|
|
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";
|
|
15
19
|
import { spawn } from "child_process";
|
|
16
20
|
//#region \0rolldown/runtime.js
|
|
17
21
|
var __create = Object.create;
|
|
@@ -341,7 +345,7 @@ var drainIncoming = (incoming) => {
|
|
|
341
345
|
incomingWithDrainState[incomingDraining] = true;
|
|
342
346
|
if (incoming instanceof Http2ServerRequest) {
|
|
343
347
|
try {
|
|
344
|
-
incoming.stream?.close?.(constants.NGHTTP2_NO_ERROR);
|
|
348
|
+
incoming.stream?.close?.(constants$1.NGHTTP2_NO_ERROR);
|
|
345
349
|
} catch {}
|
|
346
350
|
return;
|
|
347
351
|
}
|
|
@@ -2518,17 +2522,17 @@ var colorStatus = async (status) => {
|
|
|
2518
2522
|
}
|
|
2519
2523
|
return `${status}`;
|
|
2520
2524
|
};
|
|
2521
|
-
async function log(fn, prefix, method, path, status = 0, elapsed) {
|
|
2525
|
+
async function log$1(fn, prefix, method, path, status = 0, elapsed) {
|
|
2522
2526
|
fn(prefix === "<--" ? `${prefix} ${method} ${path}` : `${prefix} ${method} ${path} ${await colorStatus(status)} ${elapsed}`);
|
|
2523
2527
|
}
|
|
2524
2528
|
var logger$1 = (fn = console.log) => {
|
|
2525
2529
|
return async function logger2(c, next) {
|
|
2526
2530
|
const { method, url } = c.req;
|
|
2527
2531
|
const path = url.slice(url.indexOf("/", 8));
|
|
2528
|
-
await log(fn, "<--", method, path);
|
|
2532
|
+
await log$1(fn, "<--", method, path);
|
|
2529
2533
|
const start = Date.now();
|
|
2530
2534
|
await next();
|
|
2531
|
-
await log(fn, "-->", method, path, c.res.status, time(start));
|
|
2535
|
+
await log$1(fn, "-->", method, path, c.res.status, time(start));
|
|
2532
2536
|
};
|
|
2533
2537
|
};
|
|
2534
2538
|
//#endregion
|
|
@@ -6785,24 +6789,11 @@ function migrate(db, config) {
|
|
|
6785
6789
|
//#endregion
|
|
6786
6790
|
//#region src/config/index.ts
|
|
6787
6791
|
const configDirectory = join(homedir(), ".jukebox");
|
|
6788
|
-
|
|
6792
|
+
join(configDirectory, "config.json");
|
|
6789
6793
|
const databasePath = join(configDirectory, "jukebox.db");
|
|
6790
6794
|
function ensureConfigDirectory() {
|
|
6791
6795
|
mkdirSync(configDirectory, { recursive: true });
|
|
6792
6796
|
}
|
|
6793
|
-
function getConfig() {
|
|
6794
|
-
if (!existsSync(configFilePath)) return null;
|
|
6795
|
-
try {
|
|
6796
|
-
const raw = readFileSync(configFilePath, "utf-8");
|
|
6797
|
-
return JSON.parse(raw);
|
|
6798
|
-
} catch {
|
|
6799
|
-
return null;
|
|
6800
|
-
}
|
|
6801
|
-
}
|
|
6802
|
-
async function saveConfig(config) {
|
|
6803
|
-
ensureConfigDirectory();
|
|
6804
|
-
await writeFile(configFilePath, JSON.stringify(config, null, 2));
|
|
6805
|
-
}
|
|
6806
6797
|
//#endregion
|
|
6807
6798
|
//#region src/database/schema.ts
|
|
6808
6799
|
var schema_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -6812,9 +6803,12 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
6812
6803
|
libraries: () => libraries,
|
|
6813
6804
|
movies: () => movies,
|
|
6814
6805
|
profiles: () => profiles,
|
|
6806
|
+
scanJobs: () => scanJobs,
|
|
6815
6807
|
seasons: () => seasons,
|
|
6816
6808
|
sessions: () => sessions$1,
|
|
6809
|
+
settings: () => settings,
|
|
6817
6810
|
shows: () => shows,
|
|
6811
|
+
subtitles: () => subtitles,
|
|
6818
6812
|
watchProgress: () => watchProgress
|
|
6819
6813
|
});
|
|
6820
6814
|
const profiles = sqliteTable("profiles", {
|
|
@@ -6839,27 +6833,27 @@ const movies = sqliteTable("movies", {
|
|
|
6839
6833
|
extension: text("extension"),
|
|
6840
6834
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
6841
6835
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
|
|
6842
|
-
|
|
6836
|
+
externalId: text("external_id"),
|
|
6843
6837
|
year: integer("year"),
|
|
6844
6838
|
overview: text("overview"),
|
|
6845
6839
|
runtime: integer("runtime"),
|
|
6846
6840
|
genres: text("genres"),
|
|
6847
6841
|
rating: real("rating"),
|
|
6848
|
-
|
|
6849
|
-
|
|
6842
|
+
posterUrl: text("poster_url"),
|
|
6843
|
+
backdropUrl: text("backdrop_url"),
|
|
6850
6844
|
trailerUrl: text("trailer_url")
|
|
6851
6845
|
});
|
|
6852
6846
|
const shows = sqliteTable("shows", {
|
|
6853
6847
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6854
6848
|
title: text("title").notNull(),
|
|
6855
6849
|
folderPath: text("folder_path").notNull().unique(),
|
|
6856
|
-
|
|
6850
|
+
externalId: text("external_id"),
|
|
6857
6851
|
year: integer("year"),
|
|
6858
6852
|
overview: text("overview"),
|
|
6859
6853
|
genres: text("genres"),
|
|
6860
6854
|
rating: real("rating"),
|
|
6861
|
-
|
|
6862
|
-
|
|
6855
|
+
posterUrl: text("poster_url"),
|
|
6856
|
+
backdropUrl: text("backdrop_url"),
|
|
6863
6857
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
6864
6858
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
|
|
6865
6859
|
});
|
|
@@ -6869,7 +6863,7 @@ const seasons = sqliteTable("seasons", {
|
|
|
6869
6863
|
seasonNumber: integer("season_number").notNull(),
|
|
6870
6864
|
name: text("name"),
|
|
6871
6865
|
overview: text("overview"),
|
|
6872
|
-
|
|
6866
|
+
posterUrl: text("poster_url"),
|
|
6873
6867
|
episodeCount: integer("episode_count")
|
|
6874
6868
|
});
|
|
6875
6869
|
const episodes = sqliteTable("episodes", {
|
|
@@ -6883,18 +6877,18 @@ const episodes = sqliteTable("episodes", {
|
|
|
6883
6877
|
fileName: text("file_name").notNull(),
|
|
6884
6878
|
fileSize: integer("file_size"),
|
|
6885
6879
|
extension: text("extension"),
|
|
6886
|
-
|
|
6880
|
+
externalId: text("external_id"),
|
|
6887
6881
|
overview: text("overview"),
|
|
6888
6882
|
runtime: integer("runtime"),
|
|
6889
|
-
|
|
6883
|
+
stillUrl: text("still_url"),
|
|
6890
6884
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
6891
6885
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
|
|
6892
6886
|
});
|
|
6893
6887
|
const watchProgress = sqliteTable("watch_progress", {
|
|
6894
6888
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6895
6889
|
profileId: integer("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
|
|
6896
|
-
movieId: integer("movie_id").references(() => movies.id),
|
|
6897
|
-
episodeId: integer("episode_id").references(() => episodes.id),
|
|
6890
|
+
movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
|
|
6891
|
+
episodeId: integer("episode_id").references(() => episodes.id, { onDelete: "cascade" }),
|
|
6898
6892
|
currentTime: integer("current_time").notNull(),
|
|
6899
6893
|
duration: integer("duration"),
|
|
6900
6894
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
|
|
@@ -6918,10 +6912,34 @@ const sessions$1 = sqliteTable("sessions", {
|
|
|
6918
6912
|
lastSeenAt: integer("last_seen_at").notNull(),
|
|
6919
6913
|
userAgent: text("user_agent")
|
|
6920
6914
|
});
|
|
6915
|
+
const settings = sqliteTable("settings", {
|
|
6916
|
+
key: text("key").primaryKey(),
|
|
6917
|
+
value: text("value").notNull(),
|
|
6918
|
+
updatedAt: integer("updated_at").notNull()
|
|
6919
|
+
});
|
|
6920
|
+
const scanJobs = sqliteTable("scan_jobs", {
|
|
6921
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6922
|
+
startedAt: integer("started_at", { mode: "timestamp" }).notNull(),
|
|
6923
|
+
endedAt: integer("ended_at", { mode: "timestamp" }),
|
|
6924
|
+
status: text("status").notNull(),
|
|
6925
|
+
added: integer("added").notNull().default(0),
|
|
6926
|
+
updated: integer("updated").notNull().default(0),
|
|
6927
|
+
total: integer("total").notNull().default(0),
|
|
6928
|
+
errorMessage: text("error_message")
|
|
6929
|
+
});
|
|
6930
|
+
const subtitles = sqliteTable("subtitles", {
|
|
6931
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6932
|
+
movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
|
|
6933
|
+
episodeId: integer("episode_id").references(() => episodes.id, { onDelete: "cascade" }),
|
|
6934
|
+
filePath: text("file_path").notNull(),
|
|
6935
|
+
language: text("language").notNull(),
|
|
6936
|
+
format: text("format").notNull()
|
|
6937
|
+
});
|
|
6921
6938
|
//#endregion
|
|
6922
6939
|
//#region src/database/index.ts
|
|
6923
6940
|
ensureConfigDirectory();
|
|
6924
6941
|
const sqlite = new Database(databasePath);
|
|
6942
|
+
sqlite.pragma("foreign_keys = ON");
|
|
6925
6943
|
const db = drizzle(sqlite, { schema: schema_exports });
|
|
6926
6944
|
const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
|
|
6927
6945
|
migrate(db, { migrationsFolder: path.resolve(__dirname$2, "../../drizzle") });
|
|
@@ -7593,6 +7611,78 @@ episodeStreamRoutes.get("/:id", async (context) => {
|
|
|
7593
7611
|
} });
|
|
7594
7612
|
});
|
|
7595
7613
|
//#endregion
|
|
7614
|
+
//#region src/api/routes/filesystem.ts
|
|
7615
|
+
async function listWindowsDrives() {
|
|
7616
|
+
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
|
7617
|
+
const drives = [];
|
|
7618
|
+
await Promise.all(letters.map(async (letter) => {
|
|
7619
|
+
const drivePath = `${letter}:\\`;
|
|
7620
|
+
try {
|
|
7621
|
+
await access(drivePath, constants.R_OK);
|
|
7622
|
+
drives.push({
|
|
7623
|
+
name: `${letter}:`,
|
|
7624
|
+
path: drivePath
|
|
7625
|
+
});
|
|
7626
|
+
} catch {}
|
|
7627
|
+
}));
|
|
7628
|
+
drives.sort((a, b) => a.name.localeCompare(b.name));
|
|
7629
|
+
return drives;
|
|
7630
|
+
}
|
|
7631
|
+
function listPosixRoots() {
|
|
7632
|
+
const entries = [{
|
|
7633
|
+
name: "/",
|
|
7634
|
+
path: "/"
|
|
7635
|
+
}];
|
|
7636
|
+
const home = homedir();
|
|
7637
|
+
if (home && home !== "/") entries.push({
|
|
7638
|
+
name: `Home (${home})`,
|
|
7639
|
+
path: home
|
|
7640
|
+
});
|
|
7641
|
+
return entries;
|
|
7642
|
+
}
|
|
7643
|
+
async function listRoots() {
|
|
7644
|
+
return {
|
|
7645
|
+
entries: process.platform === "win32" ? await listWindowsDrives() : listPosixRoots(),
|
|
7646
|
+
parent: null,
|
|
7647
|
+
path: "",
|
|
7648
|
+
separator: path.sep
|
|
7649
|
+
};
|
|
7650
|
+
}
|
|
7651
|
+
function isAtFilesystemRoot(resolved) {
|
|
7652
|
+
return path.dirname(resolved) === resolved;
|
|
7653
|
+
}
|
|
7654
|
+
async function listDirectory(inputPath) {
|
|
7655
|
+
const resolved = path.resolve(inputPath);
|
|
7656
|
+
return {
|
|
7657
|
+
entries: (await readdir(resolved, { withFileTypes: true })).filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith(".")).map((dirent) => ({
|
|
7658
|
+
name: dirent.name,
|
|
7659
|
+
path: path.join(resolved, dirent.name)
|
|
7660
|
+
})).sort((a, b) => a.name.localeCompare(b.name, void 0, { sensitivity: "base" })),
|
|
7661
|
+
parent: isAtFilesystemRoot(resolved) ? null : path.dirname(resolved),
|
|
7662
|
+
path: resolved,
|
|
7663
|
+
separator: path.sep
|
|
7664
|
+
};
|
|
7665
|
+
}
|
|
7666
|
+
const filesystemRoutes = new Hono();
|
|
7667
|
+
filesystemRoutes.get("/browse", async (context) => {
|
|
7668
|
+
const rawPath = context.req.query("path");
|
|
7669
|
+
const trimmed = typeof rawPath === "string" ? rawPath.trim() : "";
|
|
7670
|
+
if (trimmed.length === 0) {
|
|
7671
|
+
const response = await listRoots();
|
|
7672
|
+
return context.json(response);
|
|
7673
|
+
}
|
|
7674
|
+
try {
|
|
7675
|
+
const response = await listDirectory(trimmed);
|
|
7676
|
+
return context.json(response);
|
|
7677
|
+
} catch (caught) {
|
|
7678
|
+
const code = caught && typeof caught === "object" && "code" in caught ? String(caught.code) : "unknown";
|
|
7679
|
+
if (code === "ENOENT") return context.json({ error: { message: `Folder doesn't exist: ${trimmed}. Check the path and try again.` } }, 404);
|
|
7680
|
+
if (code === "EACCES" || code === "EPERM") return context.json({ error: { message: `Jukebox doesn't have permission to read ${trimmed}. Check the server's file permissions.` } }, 403);
|
|
7681
|
+
if (code === "ENOTDIR") return context.json({ error: { message: `${trimmed} is a file, not a folder. Pick a folder instead.` } }, 400);
|
|
7682
|
+
return context.json({ error: { message: `Couldn't list ${trimmed}: ${code}.` } }, 500);
|
|
7683
|
+
}
|
|
7684
|
+
});
|
|
7685
|
+
//#endregion
|
|
7596
7686
|
//#region src/api/routes/hello.ts
|
|
7597
7687
|
const helloRoutes = new Hono();
|
|
7598
7688
|
helloRoutes.get("/", (c) => c.json({
|
|
@@ -7605,6 +7695,309 @@ helloRoutes.put("/", (c) => c.json({
|
|
|
7605
7695
|
}));
|
|
7606
7696
|
helloRoutes.get("/:name", (c) => c.json({ message: `Hello, ${c.req.param("name")}!` }));
|
|
7607
7697
|
//#endregion
|
|
7698
|
+
//#region src/services/filename-parser.ts
|
|
7699
|
+
const subtitleExtensions$1 = {
|
|
7700
|
+
".ass": "ass",
|
|
7701
|
+
".srt": "srt",
|
|
7702
|
+
".vtt": "vtt"
|
|
7703
|
+
};
|
|
7704
|
+
const threeLetterToTwoLetter = {
|
|
7705
|
+
ara: "ar",
|
|
7706
|
+
ces: "cs",
|
|
7707
|
+
cze: "cs",
|
|
7708
|
+
dan: "da",
|
|
7709
|
+
deu: "de",
|
|
7710
|
+
dut: "nl",
|
|
7711
|
+
ell: "el",
|
|
7712
|
+
eng: "en",
|
|
7713
|
+
fin: "fi",
|
|
7714
|
+
fra: "fr",
|
|
7715
|
+
fre: "fr",
|
|
7716
|
+
ger: "de",
|
|
7717
|
+
gre: "el",
|
|
7718
|
+
heb: "he",
|
|
7719
|
+
hin: "hi",
|
|
7720
|
+
hun: "hu",
|
|
7721
|
+
ind: "id",
|
|
7722
|
+
ita: "it",
|
|
7723
|
+
jpn: "ja",
|
|
7724
|
+
kor: "ko",
|
|
7725
|
+
nld: "nl",
|
|
7726
|
+
nor: "no",
|
|
7727
|
+
pol: "pl",
|
|
7728
|
+
por: "pt",
|
|
7729
|
+
ron: "ro",
|
|
7730
|
+
rum: "ro",
|
|
7731
|
+
rus: "ru",
|
|
7732
|
+
spa: "es",
|
|
7733
|
+
swe: "sv",
|
|
7734
|
+
tha: "th",
|
|
7735
|
+
tur: "tr",
|
|
7736
|
+
ukr: "uk",
|
|
7737
|
+
vie: "vi",
|
|
7738
|
+
zho: "zh",
|
|
7739
|
+
chi: "zh"
|
|
7740
|
+
};
|
|
7741
|
+
const fullNameToCode = {
|
|
7742
|
+
arabic: "ar",
|
|
7743
|
+
chinese: "zh",
|
|
7744
|
+
czech: "cs",
|
|
7745
|
+
danish: "da",
|
|
7746
|
+
dutch: "nl",
|
|
7747
|
+
english: "en",
|
|
7748
|
+
finnish: "fi",
|
|
7749
|
+
french: "fr",
|
|
7750
|
+
german: "de",
|
|
7751
|
+
greek: "el",
|
|
7752
|
+
hebrew: "he",
|
|
7753
|
+
hindi: "hi",
|
|
7754
|
+
hungarian: "hu",
|
|
7755
|
+
indonesian: "id",
|
|
7756
|
+
italian: "it",
|
|
7757
|
+
japanese: "ja",
|
|
7758
|
+
korean: "ko",
|
|
7759
|
+
norwegian: "no",
|
|
7760
|
+
polish: "pl",
|
|
7761
|
+
portuguese: "pt",
|
|
7762
|
+
romanian: "ro",
|
|
7763
|
+
russian: "ru",
|
|
7764
|
+
spanish: "es",
|
|
7765
|
+
swedish: "sv",
|
|
7766
|
+
thai: "th",
|
|
7767
|
+
turkish: "tr",
|
|
7768
|
+
ukrainian: "uk",
|
|
7769
|
+
vietnamese: "vi"
|
|
7770
|
+
};
|
|
7771
|
+
const subtitleModifiers = new Set([
|
|
7772
|
+
"cc",
|
|
7773
|
+
"forced",
|
|
7774
|
+
"hoh",
|
|
7775
|
+
"sdh"
|
|
7776
|
+
]);
|
|
7777
|
+
/**
|
|
7778
|
+
* Parse a movie filename to extract title and year.
|
|
7779
|
+
* The year marks the boundary between the title and technical info.
|
|
7780
|
+
*
|
|
7781
|
+
* Examples:
|
|
7782
|
+
* - Jurassic.Park.1993.720p.BrRip.264.YIFY.mp4 -> { title: "Jurassic Park", year: 1993 }
|
|
7783
|
+
* - The.Social.Network.(2010).1080p.BrRip.x264.mp4 -> { title: "The Social Network", year: 2010 }
|
|
7784
|
+
*/
|
|
7785
|
+
function parseFilename(fileName) {
|
|
7786
|
+
const name = fileName.replace(/\.[^.]+$/, "");
|
|
7787
|
+
const bracketYearMatch = name.match(/[([]((?:19|20)\d{2})[)\]]/);
|
|
7788
|
+
if (bracketYearMatch?.[1]) {
|
|
7789
|
+
const year = parseInt(bracketYearMatch[1], 10);
|
|
7790
|
+
const yearIndex = bracketYearMatch.index ?? 0;
|
|
7791
|
+
let title = name.substring(0, yearIndex);
|
|
7792
|
+
title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
|
|
7793
|
+
title = title.replace(/[-–—]+$/, "").trim();
|
|
7794
|
+
return {
|
|
7795
|
+
title,
|
|
7796
|
+
year
|
|
7797
|
+
};
|
|
7798
|
+
}
|
|
7799
|
+
const yearPattern = /[.\s]((?:19|20)\d{2})[.\s]/g;
|
|
7800
|
+
let match;
|
|
7801
|
+
let bestMatch = null;
|
|
7802
|
+
while ((match = yearPattern.exec(name)) !== null) {
|
|
7803
|
+
const yearStr = match[1];
|
|
7804
|
+
if (!yearStr) continue;
|
|
7805
|
+
const year = parseInt(yearStr, 10);
|
|
7806
|
+
const afterYear = name.substring(match.index + match[0].length);
|
|
7807
|
+
const looksLikeTechInfo = /^(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|HDTV|DVDRip|x264|x265|H\.?264)/i.test(afterYear);
|
|
7808
|
+
if (looksLikeTechInfo || !bestMatch) {
|
|
7809
|
+
bestMatch = {
|
|
7810
|
+
year,
|
|
7811
|
+
index: match.index
|
|
7812
|
+
};
|
|
7813
|
+
if (looksLikeTechInfo) break;
|
|
7814
|
+
}
|
|
7815
|
+
}
|
|
7816
|
+
if (bestMatch) {
|
|
7817
|
+
let title = name.substring(0, bestMatch.index);
|
|
7818
|
+
title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
|
|
7819
|
+
title = title.replace(/[-–—]+$/, "").trim();
|
|
7820
|
+
return {
|
|
7821
|
+
title,
|
|
7822
|
+
year: bestMatch.year
|
|
7823
|
+
};
|
|
7824
|
+
}
|
|
7825
|
+
return {
|
|
7826
|
+
title: name.replace(/[._]/g, " ").replace(/\s+/g, " ").trim(),
|
|
7827
|
+
year: null
|
|
7828
|
+
};
|
|
7829
|
+
}
|
|
7830
|
+
/**
|
|
7831
|
+
* Extract year from a filename if present
|
|
7832
|
+
*/
|
|
7833
|
+
function extractYear(fileName) {
|
|
7834
|
+
return parseFilename(fileName).year;
|
|
7835
|
+
}
|
|
7836
|
+
/**
|
|
7837
|
+
* Clean a movie filename to extract a readable title
|
|
7838
|
+
*/
|
|
7839
|
+
function cleanTitle(fileName) {
|
|
7840
|
+
return parseFilename(fileName).title;
|
|
7841
|
+
}
|
|
7842
|
+
function normalizeLanguageToken(rawToken) {
|
|
7843
|
+
const token = rawToken.toLowerCase();
|
|
7844
|
+
if (subtitleModifiers.has(token)) return null;
|
|
7845
|
+
if (fullNameToCode[token]) return fullNameToCode[token];
|
|
7846
|
+
if (threeLetterToTwoLetter[token]) return threeLetterToTwoLetter[token];
|
|
7847
|
+
if (/^[a-z]{2}$/.test(token)) return token;
|
|
7848
|
+
if (/^[a-z]{3}$/.test(token)) return token;
|
|
7849
|
+
return null;
|
|
7850
|
+
}
|
|
7851
|
+
/**
|
|
7852
|
+
* Parse a sidecar subtitle filename. Returns the base name (the video filename
|
|
7853
|
+
* without the language suffix or extension), the detected language as a
|
|
7854
|
+
* lowercase code (ISO-639-1 when known, otherwise the raw token, or 'und' when
|
|
7855
|
+
* the filename has no language hint), and the subtitle format.
|
|
7856
|
+
*
|
|
7857
|
+
* Returns null when the file isn't a recognized subtitle format.
|
|
7858
|
+
*
|
|
7859
|
+
* Supported patterns:
|
|
7860
|
+
* - Movie.srt → language 'und'
|
|
7861
|
+
* - Movie.en.srt / Movie.eng.srt → 'en'
|
|
7862
|
+
* - Movie.English.srt → 'en'
|
|
7863
|
+
* - Movie.en.forced.srt → 'en' (forced/sdh/cc/hoh modifiers ignored)
|
|
7864
|
+
* - Show.S01E02.en.srt → baseName 'Show.S01E02', language 'en'
|
|
7865
|
+
*/
|
|
7866
|
+
function parseSubtitleFilename(fileName) {
|
|
7867
|
+
const lastDot = fileName.lastIndexOf(".");
|
|
7868
|
+
if (lastDot < 0) return null;
|
|
7869
|
+
const format = subtitleExtensions$1[fileName.substring(lastDot).toLowerCase()];
|
|
7870
|
+
if (!format) return null;
|
|
7871
|
+
const tokens = fileName.substring(0, lastDot).split(".");
|
|
7872
|
+
let language = "und";
|
|
7873
|
+
let baseEndIndex = tokens.length;
|
|
7874
|
+
for (let cursor = tokens.length - 1; cursor >= 1; cursor--) {
|
|
7875
|
+
const token = tokens[cursor];
|
|
7876
|
+
if (!token) continue;
|
|
7877
|
+
const lowered = token.toLowerCase();
|
|
7878
|
+
if (subtitleModifiers.has(lowered)) {
|
|
7879
|
+
baseEndIndex = cursor;
|
|
7880
|
+
continue;
|
|
7881
|
+
}
|
|
7882
|
+
const normalized = normalizeLanguageToken(token);
|
|
7883
|
+
if (normalized) {
|
|
7884
|
+
language = normalized;
|
|
7885
|
+
baseEndIndex = cursor;
|
|
7886
|
+
}
|
|
7887
|
+
break;
|
|
7888
|
+
}
|
|
7889
|
+
return {
|
|
7890
|
+
baseName: tokens.slice(0, baseEndIndex).join("."),
|
|
7891
|
+
format,
|
|
7892
|
+
language
|
|
7893
|
+
};
|
|
7894
|
+
}
|
|
7895
|
+
//#endregion
|
|
7896
|
+
//#region src/services/media-extensions.ts
|
|
7897
|
+
const videoExtensions$1 = new Set([
|
|
7898
|
+
".mp4",
|
|
7899
|
+
".mkv",
|
|
7900
|
+
".avi",
|
|
7901
|
+
".mov",
|
|
7902
|
+
".wmv",
|
|
7903
|
+
".m4v",
|
|
7904
|
+
".webm",
|
|
7905
|
+
".flv",
|
|
7906
|
+
".mpeg",
|
|
7907
|
+
".mpg"
|
|
7908
|
+
]);
|
|
7909
|
+
const subtitleExtensions = new Set([
|
|
7910
|
+
".ass",
|
|
7911
|
+
".srt",
|
|
7912
|
+
".vtt"
|
|
7913
|
+
]);
|
|
7914
|
+
//#endregion
|
|
7915
|
+
//#region src/services/subtitles.ts
|
|
7916
|
+
const languageNames = {
|
|
7917
|
+
ar: "Arabic",
|
|
7918
|
+
cs: "Czech",
|
|
7919
|
+
da: "Danish",
|
|
7920
|
+
de: "German",
|
|
7921
|
+
el: "Greek",
|
|
7922
|
+
en: "English",
|
|
7923
|
+
es: "Spanish",
|
|
7924
|
+
fi: "Finnish",
|
|
7925
|
+
fr: "French",
|
|
7926
|
+
he: "Hebrew",
|
|
7927
|
+
hi: "Hindi",
|
|
7928
|
+
hu: "Hungarian",
|
|
7929
|
+
id: "Indonesian",
|
|
7930
|
+
it: "Italian",
|
|
7931
|
+
ja: "Japanese",
|
|
7932
|
+
ko: "Korean",
|
|
7933
|
+
nl: "Dutch",
|
|
7934
|
+
no: "Norwegian",
|
|
7935
|
+
pl: "Polish",
|
|
7936
|
+
pt: "Portuguese",
|
|
7937
|
+
ro: "Romanian",
|
|
7938
|
+
ru: "Russian",
|
|
7939
|
+
sv: "Swedish",
|
|
7940
|
+
th: "Thai",
|
|
7941
|
+
tr: "Turkish",
|
|
7942
|
+
uk: "Ukrainian",
|
|
7943
|
+
vi: "Vietnamese",
|
|
7944
|
+
zh: "Chinese"
|
|
7945
|
+
};
|
|
7946
|
+
/**
|
|
7947
|
+
* Map a language code to a human-readable label. Returns "Unknown" for the
|
|
7948
|
+
* undetermined sentinel and the raw code for languages not in our table.
|
|
7949
|
+
*/
|
|
7950
|
+
function languageDisplayName(code) {
|
|
7951
|
+
if (code === "und") return "Unknown";
|
|
7952
|
+
return languageNames[code] ?? code;
|
|
7953
|
+
}
|
|
7954
|
+
function stripVideoExtension(fileName) {
|
|
7955
|
+
const extension = extname(fileName);
|
|
7956
|
+
if (!extension) return fileName;
|
|
7957
|
+
return fileName.substring(0, fileName.length - extension.length);
|
|
7958
|
+
}
|
|
7959
|
+
/**
|
|
7960
|
+
* Match sidecar subtitles to a video file. Siblings must already be filtered
|
|
7961
|
+
* to subtitle entries (see `readSubtitleSiblings`). A subtitle matches when
|
|
7962
|
+
* its base name (after stripping language suffix and modifiers) equals the
|
|
7963
|
+
* video filename without extension.
|
|
7964
|
+
*
|
|
7965
|
+
* Results are returned in stable order (by file path) so callers can compare
|
|
7966
|
+
* against existing rows without spurious diffs.
|
|
7967
|
+
*/
|
|
7968
|
+
function discoverSubtitlesForVideo(videoFilePath, siblingSubtitleFiles) {
|
|
7969
|
+
const directory = dirname(videoFilePath);
|
|
7970
|
+
const videoStem = stripVideoExtension(basename(videoFilePath));
|
|
7971
|
+
const matches = [];
|
|
7972
|
+
for (const sibling of siblingSubtitleFiles) {
|
|
7973
|
+
const parsed = parseSubtitleFilename(sibling);
|
|
7974
|
+
if (!parsed) continue;
|
|
7975
|
+
if (parsed.baseName !== videoStem) continue;
|
|
7976
|
+
matches.push({
|
|
7977
|
+
filePath: join(directory, sibling),
|
|
7978
|
+
format: parsed.format,
|
|
7979
|
+
language: parsed.language
|
|
7980
|
+
});
|
|
7981
|
+
}
|
|
7982
|
+
matches.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
7983
|
+
return matches;
|
|
7984
|
+
}
|
|
7985
|
+
/**
|
|
7986
|
+
* Convert SubRip (.srt) text into WebVTT (.vtt) text. Browsers don't natively
|
|
7987
|
+
* support .srt, so the streaming endpoint converts on the fly.
|
|
7988
|
+
*
|
|
7989
|
+
* Handles UTF-8 BOM, CRLF line endings, and the comma-vs-period decimal in
|
|
7990
|
+
* timestamps. Cue identifiers are preserved (they're optional in WebVTT but
|
|
7991
|
+
* harmless when present).
|
|
7992
|
+
*/
|
|
7993
|
+
function convertSrtToVtt(srt) {
|
|
7994
|
+
let body = srt;
|
|
7995
|
+
if (body.charCodeAt(0) === 65279) body = body.substring(1);
|
|
7996
|
+
body = body.replace(/\r\n?/g, "\n");
|
|
7997
|
+
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");
|
|
7998
|
+
return `WEBVTT\n\n${body.trimStart()}`;
|
|
7999
|
+
}
|
|
8000
|
+
//#endregion
|
|
7608
8001
|
//#region src/api/routes/library.ts
|
|
7609
8002
|
const libraryRoutes = new Hono();
|
|
7610
8003
|
libraryRoutes.get("/movies", async (c) => {
|
|
@@ -7614,9 +8007,19 @@ libraryRoutes.get("/movies", async (c) => {
|
|
|
7614
8007
|
libraryRoutes.get("/movies/:id", async (c) => {
|
|
7615
8008
|
const id = parseInt(c.req.param("id"), 10);
|
|
7616
8009
|
if (isNaN(id)) return c.json({ error: { message: "Invalid movie ID" } }, 400);
|
|
7617
|
-
const movie = await db.select().from(movies).where(eq(movies.id, id)).limit(1);
|
|
7618
|
-
if (movie
|
|
7619
|
-
|
|
8010
|
+
const [movie] = await db.select().from(movies).where(eq(movies.id, id)).limit(1);
|
|
8011
|
+
if (!movie) return c.json({ error: { message: "Movie not found" } }, 404);
|
|
8012
|
+
const subtitles$2 = (await db.select().from(subtitles).where(eq(subtitles.movieId, id))).map((row) => ({
|
|
8013
|
+
id: row.id,
|
|
8014
|
+
displayLanguage: languageDisplayName(row.language),
|
|
8015
|
+
format: row.format,
|
|
8016
|
+
isSupported: row.format !== "ass",
|
|
8017
|
+
language: row.language
|
|
8018
|
+
}));
|
|
8019
|
+
return c.json({
|
|
8020
|
+
...movie,
|
|
8021
|
+
subtitles: subtitles$2
|
|
8022
|
+
});
|
|
7620
8023
|
});
|
|
7621
8024
|
//#endregion
|
|
7622
8025
|
//#region src/api/routes/progress.ts
|
|
@@ -7641,11 +8044,17 @@ progressRoutes.get("/continue-watching", async (context) => {
|
|
|
7641
8044
|
...result,
|
|
7642
8045
|
type: "movie"
|
|
7643
8046
|
}));
|
|
7644
|
-
const
|
|
8047
|
+
const episodesSorted = episodeResults.map((result) => ({
|
|
7645
8048
|
...result,
|
|
7646
8049
|
type: "episode"
|
|
7647
|
-
}));
|
|
7648
|
-
const
|
|
8050
|
+
})).sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
|
8051
|
+
const seenShowIds = /* @__PURE__ */ new Set();
|
|
8052
|
+
const dedupedEpisodes = episodesSorted.filter((item) => {
|
|
8053
|
+
if (seenShowIds.has(item.show.id)) return false;
|
|
8054
|
+
seenShowIds.add(item.show.id);
|
|
8055
|
+
return true;
|
|
8056
|
+
});
|
|
8057
|
+
const combined = [...movies$2, ...dedupedEpisodes].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).slice(0, 20);
|
|
7649
8058
|
return context.json(combined);
|
|
7650
8059
|
});
|
|
7651
8060
|
progressRoutes.get("/:movieId", async (c) => {
|
|
@@ -7816,245 +8225,654 @@ var streamSSE = (c, cb, onError) => {
|
|
|
7816
8225
|
return c.newResponse(stream.responseReadable);
|
|
7817
8226
|
};
|
|
7818
8227
|
//#endregion
|
|
7819
|
-
//#region
|
|
7820
|
-
const
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
|
|
7829
|
-
|
|
7830
|
-
|
|
8228
|
+
//#region node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
8229
|
+
const ANSI_BACKGROUND_OFFSET = 10;
|
|
8230
|
+
const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
|
|
8231
|
+
const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
|
|
8232
|
+
const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
|
|
8233
|
+
const styles$1 = {
|
|
8234
|
+
modifier: {
|
|
8235
|
+
reset: [0, 0],
|
|
8236
|
+
bold: [1, 22],
|
|
8237
|
+
dim: [2, 22],
|
|
8238
|
+
italic: [3, 23],
|
|
8239
|
+
underline: [4, 24],
|
|
8240
|
+
overline: [53, 55],
|
|
8241
|
+
inverse: [7, 27],
|
|
8242
|
+
hidden: [8, 28],
|
|
8243
|
+
strikethrough: [9, 29]
|
|
8244
|
+
},
|
|
8245
|
+
color: {
|
|
8246
|
+
black: [30, 39],
|
|
8247
|
+
red: [31, 39],
|
|
8248
|
+
green: [32, 39],
|
|
8249
|
+
yellow: [33, 39],
|
|
8250
|
+
blue: [34, 39],
|
|
8251
|
+
magenta: [35, 39],
|
|
8252
|
+
cyan: [36, 39],
|
|
8253
|
+
white: [37, 39],
|
|
8254
|
+
blackBright: [90, 39],
|
|
8255
|
+
gray: [90, 39],
|
|
8256
|
+
grey: [90, 39],
|
|
8257
|
+
redBright: [91, 39],
|
|
8258
|
+
greenBright: [92, 39],
|
|
8259
|
+
yellowBright: [93, 39],
|
|
8260
|
+
blueBright: [94, 39],
|
|
8261
|
+
magentaBright: [95, 39],
|
|
8262
|
+
cyanBright: [96, 39],
|
|
8263
|
+
whiteBright: [97, 39]
|
|
8264
|
+
},
|
|
8265
|
+
bgColor: {
|
|
8266
|
+
bgBlack: [40, 49],
|
|
8267
|
+
bgRed: [41, 49],
|
|
8268
|
+
bgGreen: [42, 49],
|
|
8269
|
+
bgYellow: [43, 49],
|
|
8270
|
+
bgBlue: [44, 49],
|
|
8271
|
+
bgMagenta: [45, 49],
|
|
8272
|
+
bgCyan: [46, 49],
|
|
8273
|
+
bgWhite: [47, 49],
|
|
8274
|
+
bgBlackBright: [100, 49],
|
|
8275
|
+
bgGray: [100, 49],
|
|
8276
|
+
bgGrey: [100, 49],
|
|
8277
|
+
bgRedBright: [101, 49],
|
|
8278
|
+
bgGreenBright: [102, 49],
|
|
8279
|
+
bgYellowBright: [103, 49],
|
|
8280
|
+
bgBlueBright: [104, 49],
|
|
8281
|
+
bgMagentaBright: [105, 49],
|
|
8282
|
+
bgCyanBright: [106, 49],
|
|
8283
|
+
bgWhiteBright: [107, 49]
|
|
8284
|
+
}
|
|
8285
|
+
};
|
|
8286
|
+
Object.keys(styles$1.modifier);
|
|
8287
|
+
const foregroundColorNames = Object.keys(styles$1.color);
|
|
8288
|
+
const backgroundColorNames = Object.keys(styles$1.bgColor);
|
|
8289
|
+
[...foregroundColorNames, ...backgroundColorNames];
|
|
8290
|
+
function assembleStyles() {
|
|
8291
|
+
const codes = /* @__PURE__ */ new Map();
|
|
8292
|
+
for (const [groupName, group] of Object.entries(styles$1)) {
|
|
8293
|
+
for (const [styleName, style] of Object.entries(group)) {
|
|
8294
|
+
styles$1[styleName] = {
|
|
8295
|
+
open: `\u001B[${style[0]}m`,
|
|
8296
|
+
close: `\u001B[${style[1]}m`
|
|
8297
|
+
};
|
|
8298
|
+
group[styleName] = styles$1[styleName];
|
|
8299
|
+
codes.set(style[0], style[1]);
|
|
8300
|
+
}
|
|
8301
|
+
Object.defineProperty(styles$1, groupName, {
|
|
8302
|
+
value: group,
|
|
8303
|
+
enumerable: false
|
|
8304
|
+
});
|
|
8305
|
+
}
|
|
8306
|
+
Object.defineProperty(styles$1, "codes", {
|
|
8307
|
+
value: codes,
|
|
8308
|
+
enumerable: false
|
|
8309
|
+
});
|
|
8310
|
+
styles$1.color.close = "\x1B[39m";
|
|
8311
|
+
styles$1.bgColor.close = "\x1B[49m";
|
|
8312
|
+
styles$1.color.ansi = wrapAnsi16();
|
|
8313
|
+
styles$1.color.ansi256 = wrapAnsi256();
|
|
8314
|
+
styles$1.color.ansi16m = wrapAnsi16m();
|
|
8315
|
+
styles$1.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET);
|
|
8316
|
+
styles$1.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET);
|
|
8317
|
+
styles$1.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
|
|
8318
|
+
Object.defineProperties(styles$1, {
|
|
8319
|
+
rgbToAnsi256: {
|
|
8320
|
+
value(red, green, blue) {
|
|
8321
|
+
if (red === green && green === blue) {
|
|
8322
|
+
if (red < 8) return 16;
|
|
8323
|
+
if (red > 248) return 231;
|
|
8324
|
+
return Math.round((red - 8) / 247 * 24) + 232;
|
|
8325
|
+
}
|
|
8326
|
+
return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5);
|
|
8327
|
+
},
|
|
8328
|
+
enumerable: false
|
|
8329
|
+
},
|
|
8330
|
+
hexToRgb: {
|
|
8331
|
+
value(hex) {
|
|
8332
|
+
const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
|
|
8333
|
+
if (!matches) return [
|
|
8334
|
+
0,
|
|
8335
|
+
0,
|
|
8336
|
+
0
|
|
8337
|
+
];
|
|
8338
|
+
let [colorString] = matches;
|
|
8339
|
+
if (colorString.length === 3) colorString = [...colorString].map((character) => character + character).join("");
|
|
8340
|
+
const integer = Number.parseInt(colorString, 16);
|
|
8341
|
+
return [
|
|
8342
|
+
integer >> 16 & 255,
|
|
8343
|
+
integer >> 8 & 255,
|
|
8344
|
+
integer & 255
|
|
8345
|
+
];
|
|
8346
|
+
},
|
|
8347
|
+
enumerable: false
|
|
8348
|
+
},
|
|
8349
|
+
hexToAnsi256: {
|
|
8350
|
+
value: (hex) => styles$1.rgbToAnsi256(...styles$1.hexToRgb(hex)),
|
|
8351
|
+
enumerable: false
|
|
8352
|
+
},
|
|
8353
|
+
ansi256ToAnsi: {
|
|
8354
|
+
value(code) {
|
|
8355
|
+
if (code < 8) return 30 + code;
|
|
8356
|
+
if (code < 16) return 90 + (code - 8);
|
|
8357
|
+
let red;
|
|
8358
|
+
let green;
|
|
8359
|
+
let blue;
|
|
8360
|
+
if (code >= 232) {
|
|
8361
|
+
red = ((code - 232) * 10 + 8) / 255;
|
|
8362
|
+
green = red;
|
|
8363
|
+
blue = red;
|
|
8364
|
+
} else {
|
|
8365
|
+
code -= 16;
|
|
8366
|
+
const remainder = code % 36;
|
|
8367
|
+
red = Math.floor(code / 36) / 5;
|
|
8368
|
+
green = Math.floor(remainder / 6) / 5;
|
|
8369
|
+
blue = remainder % 6 / 5;
|
|
8370
|
+
}
|
|
8371
|
+
const value = Math.max(red, green, blue) * 2;
|
|
8372
|
+
if (value === 0) return 30;
|
|
8373
|
+
let result = 30 + (Math.round(blue) << 2 | Math.round(green) << 1 | Math.round(red));
|
|
8374
|
+
if (value === 2) result += 60;
|
|
8375
|
+
return result;
|
|
8376
|
+
},
|
|
8377
|
+
enumerable: false
|
|
8378
|
+
},
|
|
8379
|
+
rgbToAnsi: {
|
|
8380
|
+
value: (red, green, blue) => styles$1.ansi256ToAnsi(styles$1.rgbToAnsi256(red, green, blue)),
|
|
8381
|
+
enumerable: false
|
|
8382
|
+
},
|
|
8383
|
+
hexToAnsi: {
|
|
8384
|
+
value: (hex) => styles$1.ansi256ToAnsi(styles$1.hexToAnsi256(hex)),
|
|
8385
|
+
enumerable: false
|
|
8386
|
+
}
|
|
7831
8387
|
});
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
const
|
|
7839
|
-
const
|
|
7840
|
-
const
|
|
7841
|
-
|
|
8388
|
+
return styles$1;
|
|
8389
|
+
}
|
|
8390
|
+
const ansiStyles = assembleStyles();
|
|
8391
|
+
//#endregion
|
|
8392
|
+
//#region node_modules/chalk/source/vendor/supports-color/index.js
|
|
8393
|
+
function hasFlag(flag, argv = globalThis.Deno ? globalThis.Deno.args : process$1.argv) {
|
|
8394
|
+
const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
|
|
8395
|
+
const position = argv.indexOf(prefix + flag);
|
|
8396
|
+
const terminatorPosition = argv.indexOf("--");
|
|
8397
|
+
return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
|
|
8398
|
+
}
|
|
8399
|
+
const { env } = process$1;
|
|
8400
|
+
let flagForceColor;
|
|
8401
|
+
if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) flagForceColor = 0;
|
|
8402
|
+
else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) flagForceColor = 1;
|
|
8403
|
+
function envForceColor() {
|
|
8404
|
+
if ("FORCE_COLOR" in env) {
|
|
8405
|
+
if (env.FORCE_COLOR === "true") return 1;
|
|
8406
|
+
if (env.FORCE_COLOR === "false") return 0;
|
|
8407
|
+
return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3);
|
|
8408
|
+
}
|
|
8409
|
+
}
|
|
8410
|
+
function translateLevel(level) {
|
|
8411
|
+
if (level === 0) return false;
|
|
8412
|
+
return {
|
|
8413
|
+
level,
|
|
8414
|
+
hasBasic: true,
|
|
8415
|
+
has256: level >= 2,
|
|
8416
|
+
has16m: level >= 3
|
|
8417
|
+
};
|
|
8418
|
+
}
|
|
8419
|
+
function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
|
|
8420
|
+
const noFlagForceColor = envForceColor();
|
|
8421
|
+
if (noFlagForceColor !== void 0) flagForceColor = noFlagForceColor;
|
|
8422
|
+
const forceColor = sniffFlags ? flagForceColor : noFlagForceColor;
|
|
8423
|
+
if (forceColor === 0) return 0;
|
|
8424
|
+
if (sniffFlags) {
|
|
8425
|
+
if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) return 3;
|
|
8426
|
+
if (hasFlag("color=256")) return 2;
|
|
8427
|
+
}
|
|
8428
|
+
if ("TF_BUILD" in env && "AGENT_NAME" in env) return 1;
|
|
8429
|
+
if (haveStream && !streamIsTTY && forceColor === void 0) return 0;
|
|
8430
|
+
const min = forceColor || 0;
|
|
8431
|
+
if (env.TERM === "dumb") return min;
|
|
8432
|
+
if (process$1.platform === "win32") {
|
|
8433
|
+
const osRelease = os$1.release().split(".");
|
|
8434
|
+
if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) return Number(osRelease[2]) >= 14931 ? 3 : 2;
|
|
8435
|
+
return 1;
|
|
8436
|
+
}
|
|
8437
|
+
if ("CI" in env) {
|
|
8438
|
+
if ([
|
|
8439
|
+
"GITHUB_ACTIONS",
|
|
8440
|
+
"GITEA_ACTIONS",
|
|
8441
|
+
"CIRCLECI"
|
|
8442
|
+
].some((key) => key in env)) return 3;
|
|
8443
|
+
if ([
|
|
8444
|
+
"TRAVIS",
|
|
8445
|
+
"APPVEYOR",
|
|
8446
|
+
"GITLAB_CI",
|
|
8447
|
+
"BUILDKITE",
|
|
8448
|
+
"DRONE"
|
|
8449
|
+
].some((sign) => sign in env) || env.CI_NAME === "codeship") return 1;
|
|
8450
|
+
return min;
|
|
8451
|
+
}
|
|
8452
|
+
if ("TEAMCITY_VERSION" in env) return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
|
|
8453
|
+
if (env.COLORTERM === "truecolor") return 3;
|
|
8454
|
+
if (env.TERM === "xterm-kitty") return 3;
|
|
8455
|
+
if (env.TERM === "xterm-ghostty") return 3;
|
|
8456
|
+
if (env.TERM === "wezterm") return 3;
|
|
8457
|
+
if ("TERM_PROGRAM" in env) {
|
|
8458
|
+
const version = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
|
|
8459
|
+
switch (env.TERM_PROGRAM) {
|
|
8460
|
+
case "iTerm.app": return version >= 3 ? 3 : 2;
|
|
8461
|
+
case "Apple_Terminal": return 2;
|
|
8462
|
+
}
|
|
8463
|
+
}
|
|
8464
|
+
if (/-256(color)?$/i.test(env.TERM)) return 2;
|
|
8465
|
+
if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) return 1;
|
|
8466
|
+
if ("COLORTERM" in env) return 1;
|
|
8467
|
+
return min;
|
|
8468
|
+
}
|
|
8469
|
+
function createSupportsColor(stream, options = {}) {
|
|
8470
|
+
return translateLevel(_supportsColor(stream, {
|
|
8471
|
+
streamIsTTY: stream && stream.isTTY,
|
|
8472
|
+
...options
|
|
8473
|
+
}));
|
|
8474
|
+
}
|
|
8475
|
+
const supportsColor = {
|
|
8476
|
+
stdout: createSupportsColor({ isTTY: tty.isatty(1) }),
|
|
8477
|
+
stderr: createSupportsColor({ isTTY: tty.isatty(2) })
|
|
8478
|
+
};
|
|
8479
|
+
//#endregion
|
|
8480
|
+
//#region node_modules/chalk/source/utilities.js
|
|
8481
|
+
function stringReplaceAll(string, substring, replacer) {
|
|
8482
|
+
let index = string.indexOf(substring);
|
|
8483
|
+
if (index === -1) return string;
|
|
8484
|
+
const substringLength = substring.length;
|
|
8485
|
+
let endIndex = 0;
|
|
8486
|
+
let returnValue = "";
|
|
8487
|
+
do {
|
|
8488
|
+
returnValue += string.slice(endIndex, index) + substring + replacer;
|
|
8489
|
+
endIndex = index + substringLength;
|
|
8490
|
+
index = string.indexOf(substring, endIndex);
|
|
8491
|
+
} while (index !== -1);
|
|
8492
|
+
returnValue += string.slice(endIndex);
|
|
8493
|
+
return returnValue;
|
|
8494
|
+
}
|
|
8495
|
+
function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
|
|
8496
|
+
let endIndex = 0;
|
|
8497
|
+
let returnValue = "";
|
|
8498
|
+
do {
|
|
8499
|
+
const gotCR = string[index - 1] === "\r";
|
|
8500
|
+
returnValue += string.slice(endIndex, gotCR ? index - 1 : index) + prefix + (gotCR ? "\r\n" : "\n") + postfix;
|
|
8501
|
+
endIndex = index + 1;
|
|
8502
|
+
index = string.indexOf("\n", endIndex);
|
|
8503
|
+
} while (index !== -1);
|
|
8504
|
+
returnValue += string.slice(endIndex);
|
|
8505
|
+
return returnValue;
|
|
8506
|
+
}
|
|
8507
|
+
//#endregion
|
|
8508
|
+
//#region node_modules/chalk/source/index.js
|
|
8509
|
+
const { stdout: stdoutColor, stderr: stderrColor } = supportsColor;
|
|
8510
|
+
const GENERATOR = Symbol("GENERATOR");
|
|
8511
|
+
const STYLER = Symbol("STYLER");
|
|
8512
|
+
const IS_EMPTY = Symbol("IS_EMPTY");
|
|
8513
|
+
const levelMapping = [
|
|
8514
|
+
"ansi",
|
|
8515
|
+
"ansi",
|
|
8516
|
+
"ansi256",
|
|
8517
|
+
"ansi16m"
|
|
8518
|
+
];
|
|
8519
|
+
const styles = Object.create(null);
|
|
8520
|
+
const applyOptions = (object, options = {}) => {
|
|
8521
|
+
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");
|
|
8522
|
+
const colorLevel = stdoutColor ? stdoutColor.level : 0;
|
|
8523
|
+
object.level = options.level === void 0 ? colorLevel : options.level;
|
|
8524
|
+
};
|
|
8525
|
+
const chalkFactory = (options) => {
|
|
8526
|
+
const chalk = (...strings) => strings.join(" ");
|
|
8527
|
+
applyOptions(chalk, options);
|
|
8528
|
+
Object.setPrototypeOf(chalk, createChalk.prototype);
|
|
8529
|
+
return chalk;
|
|
8530
|
+
};
|
|
8531
|
+
function createChalk(options) {
|
|
8532
|
+
return chalkFactory(options);
|
|
8533
|
+
}
|
|
8534
|
+
Object.setPrototypeOf(createChalk.prototype, Function.prototype);
|
|
8535
|
+
for (const [styleName, style] of Object.entries(ansiStyles)) styles[styleName] = { get() {
|
|
8536
|
+
const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]);
|
|
8537
|
+
Object.defineProperty(this, styleName, { value: builder });
|
|
8538
|
+
return builder;
|
|
8539
|
+
} };
|
|
8540
|
+
styles.visible = { get() {
|
|
8541
|
+
const builder = createBuilder(this, this[STYLER], true);
|
|
8542
|
+
Object.defineProperty(this, "visible", { value: builder });
|
|
8543
|
+
return builder;
|
|
8544
|
+
} };
|
|
8545
|
+
const getModelAnsi = (model, level, type, ...arguments_) => {
|
|
8546
|
+
if (model === "rgb") {
|
|
8547
|
+
if (level === "ansi16m") return ansiStyles[type].ansi16m(...arguments_);
|
|
8548
|
+
if (level === "ansi256") return ansiStyles[type].ansi256(ansiStyles.rgbToAnsi256(...arguments_));
|
|
8549
|
+
return ansiStyles[type].ansi(ansiStyles.rgbToAnsi(...arguments_));
|
|
8550
|
+
}
|
|
8551
|
+
if (model === "hex") return getModelAnsi("rgb", level, type, ...ansiStyles.hexToRgb(...arguments_));
|
|
8552
|
+
return ansiStyles[type][model](...arguments_);
|
|
8553
|
+
};
|
|
8554
|
+
for (const model of [
|
|
8555
|
+
"rgb",
|
|
8556
|
+
"hex",
|
|
8557
|
+
"ansi256"
|
|
8558
|
+
]) {
|
|
8559
|
+
styles[model] = { get() {
|
|
8560
|
+
const { level } = this;
|
|
8561
|
+
return function(...arguments_) {
|
|
8562
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], "color", ...arguments_), ansiStyles.color.close, this[STYLER]);
|
|
8563
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
8564
|
+
};
|
|
8565
|
+
} };
|
|
8566
|
+
const bgModel = "bg" + model[0].toUpperCase() + model.slice(1);
|
|
8567
|
+
styles[bgModel] = { get() {
|
|
8568
|
+
const { level } = this;
|
|
8569
|
+
return function(...arguments_) {
|
|
8570
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], "bgColor", ...arguments_), ansiStyles.bgColor.close, this[STYLER]);
|
|
8571
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
8572
|
+
};
|
|
8573
|
+
} };
|
|
8574
|
+
}
|
|
8575
|
+
const proto = Object.defineProperties(() => {}, {
|
|
8576
|
+
...styles,
|
|
8577
|
+
level: {
|
|
8578
|
+
enumerable: true,
|
|
8579
|
+
get() {
|
|
8580
|
+
return this[GENERATOR].level;
|
|
8581
|
+
},
|
|
8582
|
+
set(level) {
|
|
8583
|
+
this[GENERATOR].level = level;
|
|
8584
|
+
}
|
|
8585
|
+
}
|
|
8586
|
+
});
|
|
8587
|
+
const createStyler = (open, close, parent) => {
|
|
8588
|
+
let openAll;
|
|
8589
|
+
let closeAll;
|
|
8590
|
+
if (parent === void 0) {
|
|
8591
|
+
openAll = open;
|
|
8592
|
+
closeAll = close;
|
|
8593
|
+
} else {
|
|
8594
|
+
openAll = parent.openAll + open;
|
|
8595
|
+
closeAll = close + parent.closeAll;
|
|
8596
|
+
}
|
|
8597
|
+
return {
|
|
8598
|
+
open,
|
|
8599
|
+
close,
|
|
8600
|
+
openAll,
|
|
8601
|
+
closeAll,
|
|
8602
|
+
parent
|
|
8603
|
+
};
|
|
8604
|
+
};
|
|
8605
|
+
const createBuilder = (self, _styler, _isEmpty) => {
|
|
8606
|
+
const builder = (...arguments_) => applyStyle(builder, arguments_.length === 1 ? "" + arguments_[0] : arguments_.join(" "));
|
|
8607
|
+
Object.setPrototypeOf(builder, proto);
|
|
8608
|
+
builder[GENERATOR] = self;
|
|
8609
|
+
builder[STYLER] = _styler;
|
|
8610
|
+
builder[IS_EMPTY] = _isEmpty;
|
|
8611
|
+
return builder;
|
|
8612
|
+
};
|
|
8613
|
+
const applyStyle = (self, string) => {
|
|
8614
|
+
if (self.level <= 0 || !string) return self[IS_EMPTY] ? "" : string;
|
|
8615
|
+
let styler = self[STYLER];
|
|
8616
|
+
if (styler === void 0) return string;
|
|
8617
|
+
const { openAll, closeAll } = styler;
|
|
8618
|
+
if (string.includes("\x1B")) while (styler !== void 0) {
|
|
8619
|
+
string = stringReplaceAll(string, styler.close, styler.open);
|
|
8620
|
+
styler = styler.parent;
|
|
8621
|
+
}
|
|
8622
|
+
const lfIndex = string.indexOf("\n");
|
|
8623
|
+
if (lfIndex !== -1) string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
|
|
8624
|
+
return openAll + string + closeAll;
|
|
8625
|
+
};
|
|
8626
|
+
Object.defineProperties(createChalk.prototype, styles);
|
|
8627
|
+
const chalk = createChalk();
|
|
8628
|
+
createChalk({ level: stderrColor ? stderrColor.level : 0 });
|
|
8629
|
+
//#endregion
|
|
8630
|
+
//#region node_modules/tiny-typescript-logger/dist/index.js
|
|
8631
|
+
var logLevelToName = {
|
|
8632
|
+
[10]: "trace",
|
|
8633
|
+
[20]: "debug",
|
|
8634
|
+
[30]: "info",
|
|
8635
|
+
[40]: "warn",
|
|
8636
|
+
[50]: "error",
|
|
8637
|
+
[60]: "fatal"
|
|
8638
|
+
};
|
|
8639
|
+
var logLevelToColor = {
|
|
8640
|
+
[10]: "#ECD5E3",
|
|
8641
|
+
[20]: "#ABDEE6",
|
|
8642
|
+
[30]: "#97C1A9",
|
|
8643
|
+
[40]: "#FDFD96",
|
|
8644
|
+
[50]: "#FFB6B6",
|
|
8645
|
+
[60]: "#FF6961"
|
|
8646
|
+
};
|
|
8647
|
+
var Logger = class {
|
|
8648
|
+
debug(...items) {
|
|
8649
|
+
this.log(20, ...items);
|
|
8650
|
+
}
|
|
8651
|
+
error(...items) {
|
|
8652
|
+
this.log(50, ...items);
|
|
8653
|
+
}
|
|
8654
|
+
fatal(...items) {
|
|
8655
|
+
this.log(60, ...items);
|
|
8656
|
+
}
|
|
8657
|
+
info(...items) {
|
|
8658
|
+
this.log(30, ...items);
|
|
8659
|
+
}
|
|
8660
|
+
log(level, ...items) {
|
|
8661
|
+
if (items.length === 0) return;
|
|
8662
|
+
const message = this.isString(items[0]) ? items.shift() : "";
|
|
8663
|
+
const formattedMessage = this.formatMessage(level, message);
|
|
8664
|
+
console.log(formattedMessage, ...items);
|
|
8665
|
+
}
|
|
8666
|
+
trace(...items) {
|
|
8667
|
+
this.log(10, ...items);
|
|
8668
|
+
}
|
|
8669
|
+
warn(...items) {
|
|
8670
|
+
this.log(40, ...items);
|
|
8671
|
+
}
|
|
8672
|
+
formatMessage(level, message) {
|
|
8673
|
+
const date = (/* @__PURE__ */ new Date()).toISOString();
|
|
8674
|
+
const formattedDate = chalk.dim(date);
|
|
8675
|
+
const logLevelName = logLevelToName[level] ?? "unknown";
|
|
8676
|
+
const logLevelColor = logLevelToColor[level] ?? "#f1f5f9";
|
|
8677
|
+
return [
|
|
8678
|
+
`[${chalk.hex(logLevelColor)(logLevelName.toUpperCase())}]`,
|
|
8679
|
+
formattedDate,
|
|
8680
|
+
message
|
|
8681
|
+
].join(" ");
|
|
8682
|
+
}
|
|
8683
|
+
isString(value) {
|
|
8684
|
+
return typeof value === "string";
|
|
8685
|
+
}
|
|
8686
|
+
};
|
|
8687
|
+
var log = new Logger();
|
|
8688
|
+
//#endregion
|
|
8689
|
+
//#region src/services/metadata.ts
|
|
8690
|
+
const defaultApiBaseUrl = "https://movie-data-api.artgaard.workers.dev";
|
|
8691
|
+
function getApiBaseUrl() {
|
|
8692
|
+
const fromEnv = process.env.JUKEBOX_METADATA_API_URL?.trim();
|
|
8693
|
+
if (fromEnv !== void 0 && fromEnv.length > 0) return fromEnv.replace(/\/+$/, "");
|
|
8694
|
+
return defaultApiBaseUrl;
|
|
8695
|
+
}
|
|
8696
|
+
async function apiGet(path) {
|
|
8697
|
+
const response = await fetch(`${getApiBaseUrl()}${path}`);
|
|
8698
|
+
if (response.status === 404) throw new NotFoundError(path);
|
|
8699
|
+
if (!response.ok) throw new Error(`Metadata API request failed with status ${response.status} for ${path}`);
|
|
7842
8700
|
return await response.json();
|
|
7843
8701
|
}
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
if (!response.ok) throw new Error(`TMDB movie videos failed: ${response.statusText}`);
|
|
7849
|
-
return (await response.json()).results;
|
|
7850
|
-
}
|
|
7851
|
-
function getTrailerUrl(videos) {
|
|
7852
|
-
const youtubeVideos = videos.filter((v) => v.site === "YouTube");
|
|
7853
|
-
for (const type of [
|
|
7854
|
-
"Trailer",
|
|
7855
|
-
"Teaser",
|
|
7856
|
-
"Clip"
|
|
7857
|
-
]) {
|
|
7858
|
-
const video = youtubeVideos.find((v) => v.type === type);
|
|
7859
|
-
if (video) return `https://www.youtube.com/watch?v=${video.key}`;
|
|
8702
|
+
var NotFoundError = class extends Error {
|
|
8703
|
+
constructor(path) {
|
|
8704
|
+
super(`Metadata API returned 404 for ${path}`);
|
|
8705
|
+
this.name = "NotFoundError";
|
|
7860
8706
|
}
|
|
7861
|
-
|
|
7862
|
-
|
|
8707
|
+
};
|
|
8708
|
+
function mapMovie(apiMovie) {
|
|
8709
|
+
return {
|
|
8710
|
+
externalId: apiMovie.id,
|
|
8711
|
+
title: apiMovie.title,
|
|
8712
|
+
year: apiMovie.year,
|
|
8713
|
+
overview: apiMovie.overview,
|
|
8714
|
+
runtime: apiMovie.runtime,
|
|
8715
|
+
genres: JSON.stringify(apiMovie.genres),
|
|
8716
|
+
rating: apiMovie.rating,
|
|
8717
|
+
posterUrl: apiMovie.posterUrl,
|
|
8718
|
+
backdropUrl: apiMovie.backdropUrl,
|
|
8719
|
+
trailerUrl: apiMovie.trailerUrl
|
|
8720
|
+
};
|
|
8721
|
+
}
|
|
8722
|
+
function mapShow(apiShow) {
|
|
8723
|
+
return {
|
|
8724
|
+
externalId: apiShow.id,
|
|
8725
|
+
title: apiShow.title,
|
|
8726
|
+
year: apiShow.year,
|
|
8727
|
+
overview: apiShow.overview,
|
|
8728
|
+
genres: JSON.stringify(apiShow.genres),
|
|
8729
|
+
rating: apiShow.rating,
|
|
8730
|
+
posterUrl: apiShow.posterUrl,
|
|
8731
|
+
backdropUrl: apiShow.backdropUrl,
|
|
8732
|
+
numberOfSeasons: apiShow.numberOfSeasons
|
|
8733
|
+
};
|
|
8734
|
+
}
|
|
8735
|
+
function mapSeason(apiSeason) {
|
|
8736
|
+
return {
|
|
8737
|
+
seasonNumber: apiSeason.seasonNumber,
|
|
8738
|
+
name: apiSeason.name,
|
|
8739
|
+
overview: apiSeason.overview,
|
|
8740
|
+
posterUrl: apiSeason.posterUrl,
|
|
8741
|
+
episodes: apiSeason.episodes.map((episode) => ({
|
|
8742
|
+
episodeNumber: episode.episodeNumber,
|
|
8743
|
+
title: episode.title,
|
|
8744
|
+
overview: episode.overview,
|
|
8745
|
+
runtime: episode.runtime,
|
|
8746
|
+
stillUrl: episode.stillUrl
|
|
8747
|
+
}))
|
|
8748
|
+
};
|
|
7863
8749
|
}
|
|
7864
8750
|
async function fetchMovieMetadata(title, year) {
|
|
7865
8751
|
try {
|
|
7866
|
-
|
|
7867
|
-
|
|
8752
|
+
const search = new URLSearchParams({ title });
|
|
8753
|
+
if (year !== void 0) search.set("year", year.toString());
|
|
8754
|
+
const { results } = await apiGet(`/movies/search?${search.toString()}`);
|
|
8755
|
+
const bestMatch = results[0];
|
|
8756
|
+
if (!bestMatch) return null;
|
|
8757
|
+
return mapMovie(bestMatch);
|
|
8758
|
+
} catch (caughtError) {
|
|
8759
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
8760
|
+
log.warn(`Metadata lookup for movie "${title}" (${year ?? "no year"}) failed — continuing without metadata: ${message}`);
|
|
7868
8761
|
return null;
|
|
7869
8762
|
}
|
|
7870
|
-
const results = await searchMovie(title, year);
|
|
7871
|
-
if (results.length === 0 || !results[0]) return null;
|
|
7872
|
-
const bestMatch = results[0];
|
|
7873
|
-
const [details, videos] = await Promise.all([getMovieDetails(bestMatch.id), getMovieVideos(bestMatch.id)]);
|
|
7874
|
-
const releaseYear = details.release_date ? parseInt(details.release_date.split("-")[0] ?? "0", 10) || null : null;
|
|
7875
|
-
return {
|
|
7876
|
-
tmdbId: details.id,
|
|
7877
|
-
title: details.title,
|
|
7878
|
-
year: releaseYear,
|
|
7879
|
-
overview: details.overview,
|
|
7880
|
-
runtime: details.runtime,
|
|
7881
|
-
genres: JSON.stringify(details.genres.map((g) => g.name)),
|
|
7882
|
-
rating: details.vote_average,
|
|
7883
|
-
posterPath: details.poster_path,
|
|
7884
|
-
backdropPath: details.backdrop_path,
|
|
7885
|
-
trailerUrl: getTrailerUrl(videos)
|
|
7886
|
-
};
|
|
7887
8763
|
}
|
|
7888
|
-
async function
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7897
|
-
return (await response.json()).results;
|
|
7898
|
-
}
|
|
7899
|
-
async function getShowDetails(tmdbId) {
|
|
7900
|
-
const apiKey = getApiKey();
|
|
7901
|
-
const params = new URLSearchParams({ api_key: apiKey });
|
|
7902
|
-
const response = await fetch(`${tmdbBaseUrl}/tv/${tmdbId}?${params.toString()}`);
|
|
7903
|
-
if (!response.ok) throw new Error(`TMDB show details failed: ${response.statusText}`);
|
|
7904
|
-
return await response.json();
|
|
8764
|
+
async function fetchMovieByExternalId(externalId) {
|
|
8765
|
+
try {
|
|
8766
|
+
return mapMovie(await apiGet(`/movies/${encodeURIComponent(externalId)}`));
|
|
8767
|
+
} catch (caughtError) {
|
|
8768
|
+
if (caughtError instanceof NotFoundError) return null;
|
|
8769
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
8770
|
+
log.warn(`Metadata lookup for movie id=${externalId} failed — continuing without metadata: ${message}`);
|
|
8771
|
+
return null;
|
|
8772
|
+
}
|
|
7905
8773
|
}
|
|
7906
|
-
async function
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
8774
|
+
async function fetchShowByExternalId(externalId) {
|
|
8775
|
+
try {
|
|
8776
|
+
return mapShow(await apiGet(`/shows/${encodeURIComponent(externalId)}`));
|
|
8777
|
+
} catch (caughtError) {
|
|
8778
|
+
if (caughtError instanceof NotFoundError) return null;
|
|
8779
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
8780
|
+
log.warn(`Metadata lookup for show id=${externalId} failed — continuing without metadata: ${message}`);
|
|
8781
|
+
return null;
|
|
8782
|
+
}
|
|
7912
8783
|
}
|
|
7913
8784
|
async function fetchShowMetadata(title, year) {
|
|
7914
8785
|
try {
|
|
7915
|
-
|
|
7916
|
-
|
|
8786
|
+
const search = new URLSearchParams({ title });
|
|
8787
|
+
if (year !== void 0) search.set("year", year.toString());
|
|
8788
|
+
const { results } = await apiGet(`/shows/search?${search.toString()}`);
|
|
8789
|
+
const bestMatch = results[0];
|
|
8790
|
+
if (!bestMatch) return null;
|
|
8791
|
+
return mapShow(bestMatch);
|
|
8792
|
+
} catch (caughtError) {
|
|
8793
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
8794
|
+
log.warn(`Metadata lookup for show "${title}" (${year ?? "no year"}) failed — continuing without metadata: ${message}`);
|
|
7917
8795
|
return null;
|
|
7918
8796
|
}
|
|
7919
|
-
const results = await searchShow(title, year);
|
|
7920
|
-
if (results.length === 0 || !results[0]) return null;
|
|
7921
|
-
const bestMatch = results[0];
|
|
7922
|
-
const details = await getShowDetails(bestMatch.id);
|
|
7923
|
-
const firstAirYear = details.first_air_date ? parseInt(details.first_air_date.split("-")[0] ?? "0", 10) || null : null;
|
|
7924
|
-
return {
|
|
7925
|
-
tmdbId: details.id,
|
|
7926
|
-
title: details.name,
|
|
7927
|
-
year: firstAirYear,
|
|
7928
|
-
overview: details.overview,
|
|
7929
|
-
genres: JSON.stringify(details.genres.map((g) => g.name)),
|
|
7930
|
-
rating: details.vote_average,
|
|
7931
|
-
posterPath: details.poster_path,
|
|
7932
|
-
backdropPath: details.backdrop_path,
|
|
7933
|
-
numberOfSeasons: details.number_of_seasons
|
|
7934
|
-
};
|
|
7935
8797
|
}
|
|
7936
|
-
async function fetchSeasonMetadata(
|
|
8798
|
+
async function fetchSeasonMetadata(externalId, seasonNumber) {
|
|
7937
8799
|
try {
|
|
7938
|
-
|
|
7939
|
-
} catch {
|
|
8800
|
+
return mapSeason(await apiGet(`/shows/${encodeURIComponent(externalId)}/seasons/${seasonNumber}`));
|
|
8801
|
+
} catch (caughtError) {
|
|
8802
|
+
if (caughtError instanceof NotFoundError) return null;
|
|
8803
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
8804
|
+
log.warn(`Metadata lookup for show id=${externalId} season=${seasonNumber} failed — continuing without metadata: ${message}`);
|
|
7940
8805
|
return null;
|
|
7941
8806
|
}
|
|
7942
|
-
const details = await getSeasonDetails(tmdbId, seasonNumber);
|
|
7943
|
-
return {
|
|
7944
|
-
seasonNumber: details.season_number,
|
|
7945
|
-
name: details.name,
|
|
7946
|
-
overview: details.overview,
|
|
7947
|
-
posterPath: details.poster_path,
|
|
7948
|
-
episodes: details.episodes.map((episode) => ({
|
|
7949
|
-
episodeNumber: episode.episode_number,
|
|
7950
|
-
title: episode.name,
|
|
7951
|
-
overview: episode.overview,
|
|
7952
|
-
runtime: episode.runtime,
|
|
7953
|
-
stillPath: episode.still_path
|
|
7954
|
-
}))
|
|
7955
|
-
};
|
|
7956
8807
|
}
|
|
7957
8808
|
//#endregion
|
|
7958
|
-
//#region src/services/
|
|
8809
|
+
//#region src/services/subtitle-sync.ts
|
|
7959
8810
|
/**
|
|
7960
|
-
*
|
|
7961
|
-
*
|
|
7962
|
-
*
|
|
7963
|
-
* Examples:
|
|
7964
|
-
* - Jurassic.Park.1993.720p.BrRip.264.YIFY.mp4 -> { title: "Jurassic Park", year: 1993 }
|
|
7965
|
-
* - The.Social.Network.(2010).1080p.BrRip.x264.mp4 -> { title: "The Social Network", year: 2010 }
|
|
8811
|
+
* Replace the subtitle rows attached to a movie with the freshly discovered
|
|
8812
|
+
* sidecars. We delete-and-reinsert rather than diffing so a renamed or
|
|
8813
|
+
* removed sidecar disappears on the next scan without bookkeeping.
|
|
7966
8814
|
*/
|
|
7967
|
-
function
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
|
|
7976
|
-
|
|
7977
|
-
|
|
7978
|
-
year
|
|
7979
|
-
};
|
|
7980
|
-
}
|
|
7981
|
-
const yearPattern = /[.\s]((?:19|20)\d{2})[.\s]/g;
|
|
7982
|
-
let match;
|
|
7983
|
-
let bestMatch = null;
|
|
7984
|
-
while ((match = yearPattern.exec(name)) !== null) {
|
|
7985
|
-
const yearStr = match[1];
|
|
7986
|
-
if (!yearStr) continue;
|
|
7987
|
-
const year = parseInt(yearStr, 10);
|
|
7988
|
-
const afterYear = name.substring(match.index + match[0].length);
|
|
7989
|
-
const looksLikeTechInfo = /^(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|HDTV|DVDRip|x264|x265|H\.?264)/i.test(afterYear);
|
|
7990
|
-
if (looksLikeTechInfo || !bestMatch) {
|
|
7991
|
-
bestMatch = {
|
|
7992
|
-
year,
|
|
7993
|
-
index: match.index
|
|
7994
|
-
};
|
|
7995
|
-
if (looksLikeTechInfo) break;
|
|
7996
|
-
}
|
|
7997
|
-
}
|
|
7998
|
-
if (bestMatch) {
|
|
7999
|
-
let title = name.substring(0, bestMatch.index);
|
|
8000
|
-
title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
|
|
8001
|
-
title = title.replace(/[-–—]+$/, "").trim();
|
|
8002
|
-
return {
|
|
8003
|
-
title,
|
|
8004
|
-
year: bestMatch.year
|
|
8005
|
-
};
|
|
8006
|
-
}
|
|
8007
|
-
return {
|
|
8008
|
-
title: name.replace(/[._]/g, " ").replace(/\s+/g, " ").trim(),
|
|
8009
|
-
year: null
|
|
8010
|
-
};
|
|
8011
|
-
}
|
|
8012
|
-
/**
|
|
8013
|
-
* Extract year from a filename if present
|
|
8014
|
-
*/
|
|
8015
|
-
function extractYear(fileName) {
|
|
8016
|
-
return parseFilename(fileName).year;
|
|
8815
|
+
async function syncSubtitlesForMovie(movieId, discovered) {
|
|
8816
|
+
await db.delete(subtitles).where(eq(subtitles.movieId, movieId));
|
|
8817
|
+
if (discovered.length === 0) return;
|
|
8818
|
+
const rows = discovered.map((subtitle) => ({
|
|
8819
|
+
movieId,
|
|
8820
|
+
episodeId: null,
|
|
8821
|
+
filePath: subtitle.filePath,
|
|
8822
|
+
format: subtitle.format,
|
|
8823
|
+
language: subtitle.language
|
|
8824
|
+
}));
|
|
8825
|
+
await db.insert(subtitles).values(rows);
|
|
8017
8826
|
}
|
|
8018
8827
|
/**
|
|
8019
|
-
*
|
|
8828
|
+
* Same as syncSubtitlesForMovie but for episodes. See note above.
|
|
8020
8829
|
*/
|
|
8021
|
-
function
|
|
8022
|
-
|
|
8830
|
+
async function syncSubtitlesForEpisode(episodeId, discovered) {
|
|
8831
|
+
await db.delete(subtitles).where(eq(subtitles.episodeId, episodeId));
|
|
8832
|
+
if (discovered.length === 0) return;
|
|
8833
|
+
const rows = discovered.map((subtitle) => ({
|
|
8834
|
+
movieId: null,
|
|
8835
|
+
episodeId,
|
|
8836
|
+
filePath: subtitle.filePath,
|
|
8837
|
+
format: subtitle.format,
|
|
8838
|
+
language: subtitle.language
|
|
8839
|
+
}));
|
|
8840
|
+
await db.insert(subtitles).values(rows);
|
|
8023
8841
|
}
|
|
8024
8842
|
//#endregion
|
|
8025
8843
|
//#region src/services/scanner.ts
|
|
8026
|
-
const VIDEO_EXTENSIONS = new Set([
|
|
8027
|
-
".mp4",
|
|
8028
|
-
".mkv",
|
|
8029
|
-
".avi",
|
|
8030
|
-
".mov",
|
|
8031
|
-
".wmv",
|
|
8032
|
-
".m4v",
|
|
8033
|
-
".webm",
|
|
8034
|
-
".flv",
|
|
8035
|
-
".mpeg",
|
|
8036
|
-
".mpg"
|
|
8037
|
-
]);
|
|
8038
8844
|
/**
|
|
8039
|
-
* Recursively scan a directory for video files
|
|
8845
|
+
* Recursively scan a directory for video files. Each yielded entry includes
|
|
8846
|
+
* the subtitle siblings sitting in the same folder so subtitle discovery
|
|
8847
|
+
* piggybacks on the directory read instead of re-globbing per file.
|
|
8040
8848
|
*/
|
|
8041
8849
|
async function* scanDirectory(dir) {
|
|
8042
8850
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
8851
|
+
const videoFiles = [];
|
|
8852
|
+
const subtitleSiblings = [];
|
|
8853
|
+
const subdirectories = [];
|
|
8043
8854
|
for (const entry of entries) {
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
const ext = extname(entry.name).toLowerCase();
|
|
8048
|
-
if (VIDEO_EXTENSIONS.has(ext)) yield fullPath;
|
|
8855
|
+
if (entry.isDirectory()) {
|
|
8856
|
+
subdirectories.push(entry.name);
|
|
8857
|
+
continue;
|
|
8049
8858
|
}
|
|
8050
|
-
|
|
8859
|
+
if (!entry.isFile()) continue;
|
|
8860
|
+
const extension = extname(entry.name).toLowerCase();
|
|
8861
|
+
if (videoExtensions$1.has(extension)) videoFiles.push(entry.name);
|
|
8862
|
+
else if (subtitleExtensions.has(extension)) subtitleSiblings.push(entry.name);
|
|
8863
|
+
}
|
|
8864
|
+
for (const videoFile of videoFiles) yield {
|
|
8865
|
+
filePath: join(dir, videoFile),
|
|
8866
|
+
subtitleSiblings
|
|
8867
|
+
};
|
|
8868
|
+
for (const subdirectory of subdirectories) yield* scanDirectory(join(dir, subdirectory));
|
|
8051
8869
|
}
|
|
8052
8870
|
async function scanLibrary(libraryPath, onProgress) {
|
|
8053
8871
|
let added = 0;
|
|
8054
8872
|
let updated = 0;
|
|
8055
8873
|
let total = 0;
|
|
8056
8874
|
console.log(`Scanning: ${libraryPath}`);
|
|
8057
|
-
for await (const filePath of scanDirectory(libraryPath)) {
|
|
8875
|
+
for await (const { filePath, subtitleSiblings } of scanDirectory(libraryPath)) {
|
|
8058
8876
|
total++;
|
|
8059
8877
|
const fileName = basename(filePath);
|
|
8060
8878
|
const ext = extname(fileName).toLowerCase();
|
|
@@ -8065,38 +8883,48 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
8065
8883
|
fileSize = (await stat(filePath)).size;
|
|
8066
8884
|
} catch {}
|
|
8067
8885
|
const now = /* @__PURE__ */ new Date();
|
|
8886
|
+
const discoveredSubtitles = discoverSubtitlesForVideo(filePath, subtitleSiblings);
|
|
8068
8887
|
const existing = await db.select().from(movies).where(eq(movies.filePath, filePath)).limit(1);
|
|
8888
|
+
let movieId;
|
|
8069
8889
|
if (existing.length > 0 && existing[0]) {
|
|
8070
8890
|
const movie = existing[0];
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
|
|
8891
|
+
movieId = movie.id;
|
|
8892
|
+
let metadataUpdate = {};
|
|
8893
|
+
if (!movie.externalId) {
|
|
8894
|
+
console.log(` Fetching metadata for: ${title}`);
|
|
8074
8895
|
const metadata = await fetchMovieMetadata(title, year ?? void 0);
|
|
8075
|
-
if (metadata)
|
|
8076
|
-
|
|
8896
|
+
if (metadata) metadataUpdate = {
|
|
8897
|
+
externalId: metadata.externalId,
|
|
8077
8898
|
year: metadata.year,
|
|
8078
8899
|
overview: metadata.overview,
|
|
8079
8900
|
runtime: metadata.runtime,
|
|
8080
8901
|
genres: metadata.genres,
|
|
8081
8902
|
rating: metadata.rating,
|
|
8082
|
-
|
|
8083
|
-
|
|
8903
|
+
posterUrl: metadata.posterUrl,
|
|
8904
|
+
backdropUrl: metadata.backdropUrl,
|
|
8084
8905
|
trailerUrl: metadata.trailerUrl
|
|
8085
8906
|
};
|
|
8086
|
-
} else if (!movie.
|
|
8087
|
-
console.log(`
|
|
8088
|
-
|
|
8089
|
-
|
|
8090
|
-
|
|
8091
|
-
|
|
8907
|
+
} else if (!movie.posterUrl || !movie.backdropUrl || !movie.trailerUrl) {
|
|
8908
|
+
console.log(` Refreshing metadata for: ${movie.title}`);
|
|
8909
|
+
const refreshed = await fetchMovieByExternalId(movie.externalId);
|
|
8910
|
+
if (refreshed) metadataUpdate = {
|
|
8911
|
+
year: movie.year ?? refreshed.year,
|
|
8912
|
+
overview: movie.overview ?? refreshed.overview,
|
|
8913
|
+
runtime: movie.runtime ?? refreshed.runtime,
|
|
8914
|
+
genres: movie.genres ?? refreshed.genres,
|
|
8915
|
+
rating: movie.rating ?? refreshed.rating,
|
|
8916
|
+
posterUrl: movie.posterUrl ?? refreshed.posterUrl,
|
|
8917
|
+
backdropUrl: movie.backdropUrl ?? refreshed.backdropUrl,
|
|
8918
|
+
trailerUrl: movie.trailerUrl ?? refreshed.trailerUrl
|
|
8919
|
+
};
|
|
8092
8920
|
}
|
|
8093
8921
|
await db.update(movies).set({
|
|
8094
|
-
title: movie.
|
|
8922
|
+
title: movie.externalId ? movie.title : title,
|
|
8095
8923
|
fileName,
|
|
8096
8924
|
fileSize,
|
|
8097
8925
|
extension: ext,
|
|
8098
8926
|
updatedAt: now,
|
|
8099
|
-
...
|
|
8927
|
+
...metadataUpdate
|
|
8100
8928
|
}).where(eq(movies.filePath, filePath));
|
|
8101
8929
|
updated++;
|
|
8102
8930
|
await onProgress?.({
|
|
@@ -8105,7 +8933,7 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
8105
8933
|
updated
|
|
8106
8934
|
});
|
|
8107
8935
|
} else {
|
|
8108
|
-
console.log(` Fetching
|
|
8936
|
+
console.log(` Fetching metadata for: ${title}`);
|
|
8109
8937
|
const metadata = await fetchMovieMetadata(title, year ?? void 0);
|
|
8110
8938
|
const newMovie = {
|
|
8111
8939
|
title: metadata?.title ?? title,
|
|
@@ -8115,17 +8943,17 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
8115
8943
|
extension: ext,
|
|
8116
8944
|
createdAt: now,
|
|
8117
8945
|
updatedAt: now,
|
|
8118
|
-
|
|
8946
|
+
externalId: metadata?.externalId ?? null,
|
|
8119
8947
|
year: metadata?.year ?? year,
|
|
8120
8948
|
overview: metadata?.overview ?? null,
|
|
8121
8949
|
runtime: metadata?.runtime ?? null,
|
|
8122
8950
|
genres: metadata?.genres ?? null,
|
|
8123
8951
|
rating: metadata?.rating ?? null,
|
|
8124
|
-
|
|
8125
|
-
|
|
8952
|
+
posterUrl: metadata?.posterUrl ?? null,
|
|
8953
|
+
backdropUrl: metadata?.backdropUrl ?? null,
|
|
8126
8954
|
trailerUrl: metadata?.trailerUrl ?? null
|
|
8127
8955
|
};
|
|
8128
|
-
await db.insert(movies).values(newMovie);
|
|
8956
|
+
movieId = (await db.insert(movies).values(newMovie).returning({ id: movies.id }))[0]?.id ?? null;
|
|
8129
8957
|
added++;
|
|
8130
8958
|
await onProgress?.({
|
|
8131
8959
|
added,
|
|
@@ -8133,6 +8961,7 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
8133
8961
|
updated
|
|
8134
8962
|
});
|
|
8135
8963
|
}
|
|
8964
|
+
if (movieId !== null) await syncSubtitlesForMovie(movieId, discoveredSubtitles);
|
|
8136
8965
|
console.log(` Found: ${title}`);
|
|
8137
8966
|
}
|
|
8138
8967
|
return {
|
|
@@ -8143,7 +8972,7 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
8143
8972
|
}
|
|
8144
8973
|
//#endregion
|
|
8145
8974
|
//#region src/services/episode-parser.ts
|
|
8146
|
-
const videoExtensions
|
|
8975
|
+
const videoExtensions = new Set([
|
|
8147
8976
|
".mp4",
|
|
8148
8977
|
".mkv",
|
|
8149
8978
|
".avi",
|
|
@@ -8159,7 +8988,7 @@ const episodePattern = /S(\d+)[Ee](\d+)/;
|
|
|
8159
8988
|
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;
|
|
8160
8989
|
function parseEpisodeFilename(fileName) {
|
|
8161
8990
|
const ext = extname(fileName).toLowerCase();
|
|
8162
|
-
if (!videoExtensions
|
|
8991
|
+
if (!videoExtensions.has(ext)) return null;
|
|
8163
8992
|
const name = fileName.replace(/\.[^.]+$/, "");
|
|
8164
8993
|
const match = name.match(episodePattern);
|
|
8165
8994
|
if (!match?.[1] || !match[2]) return null;
|
|
@@ -8253,18 +9082,6 @@ function parseSeasonFolder(folderName) {
|
|
|
8253
9082
|
}
|
|
8254
9083
|
//#endregion
|
|
8255
9084
|
//#region src/services/show-scanner.ts
|
|
8256
|
-
const videoExtensions = new Set([
|
|
8257
|
-
".mp4",
|
|
8258
|
-
".mkv",
|
|
8259
|
-
".avi",
|
|
8260
|
-
".mov",
|
|
8261
|
-
".wmv",
|
|
8262
|
-
".m4v",
|
|
8263
|
-
".webm",
|
|
8264
|
-
".flv",
|
|
8265
|
-
".mpeg",
|
|
8266
|
-
".mpg"
|
|
8267
|
-
]);
|
|
8268
9085
|
async function scanEpisodesInDirectory(directory) {
|
|
8269
9086
|
let entries;
|
|
8270
9087
|
try {
|
|
@@ -8272,10 +9089,16 @@ async function scanEpisodesInDirectory(directory) {
|
|
|
8272
9089
|
} catch {
|
|
8273
9090
|
return [];
|
|
8274
9091
|
}
|
|
8275
|
-
const
|
|
9092
|
+
const subtitleSiblings = [];
|
|
9093
|
+
const videoEntries = [];
|
|
8276
9094
|
for (const entry of entries) {
|
|
8277
9095
|
const extension = extname(entry).toLowerCase();
|
|
8278
|
-
if (
|
|
9096
|
+
if (videoExtensions$1.has(extension)) videoEntries.push(entry);
|
|
9097
|
+
else if (subtitleExtensions.has(extension)) subtitleSiblings.push(entry);
|
|
9098
|
+
}
|
|
9099
|
+
const episodes = [];
|
|
9100
|
+
for (const entry of videoEntries) {
|
|
9101
|
+
const extension = extname(entry).toLowerCase();
|
|
8279
9102
|
const parsed = parseEpisodeFilename(entry);
|
|
8280
9103
|
if (!parsed) continue;
|
|
8281
9104
|
const filePath = join(directory, entry);
|
|
@@ -8290,7 +9113,8 @@ async function scanEpisodesInDirectory(directory) {
|
|
|
8290
9113
|
extension,
|
|
8291
9114
|
seasonNumber: parsed.seasonNumber,
|
|
8292
9115
|
episodeNumber: parsed.episodeNumber,
|
|
8293
|
-
title: parsed.title
|
|
9116
|
+
title: parsed.title,
|
|
9117
|
+
subtitleSiblings
|
|
8294
9118
|
});
|
|
8295
9119
|
}
|
|
8296
9120
|
return episodes;
|
|
@@ -8390,29 +9214,43 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
8390
9214
|
const folderPath = discoveredShow.folders[0] ?? "";
|
|
8391
9215
|
const existingShows = await db.select().from(shows).where(eq(shows.folderPath, folderPath)).limit(1);
|
|
8392
9216
|
let showId;
|
|
8393
|
-
let
|
|
9217
|
+
let externalId;
|
|
8394
9218
|
if (existingShows.length > 0 && existingShows[0]) {
|
|
8395
|
-
|
|
8396
|
-
|
|
9219
|
+
const existingShow = existingShows[0];
|
|
9220
|
+
showId = existingShow.id;
|
|
9221
|
+
externalId = existingShow.externalId ?? null;
|
|
9222
|
+
if (externalId !== null && (!existingShow.posterUrl || !existingShow.backdropUrl || !existingShow.overview)) {
|
|
9223
|
+
console.log(` Refreshing metadata for show: ${existingShow.title}`);
|
|
9224
|
+
const refreshed = await fetchShowByExternalId(externalId);
|
|
9225
|
+
if (refreshed) await db.update(shows).set({
|
|
9226
|
+
year: existingShow.year ?? refreshed.year,
|
|
9227
|
+
overview: existingShow.overview ?? refreshed.overview,
|
|
9228
|
+
genres: existingShow.genres ?? refreshed.genres,
|
|
9229
|
+
rating: existingShow.rating ?? refreshed.rating,
|
|
9230
|
+
posterUrl: existingShow.posterUrl ?? refreshed.posterUrl,
|
|
9231
|
+
backdropUrl: existingShow.backdropUrl ?? refreshed.backdropUrl,
|
|
9232
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
9233
|
+
}).where(eq(shows.id, showId));
|
|
9234
|
+
}
|
|
8397
9235
|
} else {
|
|
8398
|
-
console.log(` Fetching
|
|
9236
|
+
console.log(` Fetching metadata for show: ${name}`);
|
|
8399
9237
|
const metadata = await fetchShowMetadata(name, year ?? void 0);
|
|
8400
9238
|
const now = /* @__PURE__ */ new Date();
|
|
8401
9239
|
const newShow = {
|
|
8402
9240
|
title: metadata?.title ?? name,
|
|
8403
9241
|
folderPath,
|
|
8404
|
-
|
|
9242
|
+
externalId: metadata?.externalId ?? null,
|
|
8405
9243
|
year: metadata?.year ?? year ?? null,
|
|
8406
9244
|
overview: metadata?.overview ?? null,
|
|
8407
9245
|
genres: metadata?.genres ?? null,
|
|
8408
9246
|
rating: metadata?.rating ?? null,
|
|
8409
|
-
|
|
8410
|
-
|
|
9247
|
+
posterUrl: metadata?.posterUrl ?? null,
|
|
9248
|
+
backdropUrl: metadata?.backdropUrl ?? null,
|
|
8411
9249
|
createdAt: now,
|
|
8412
9250
|
updatedAt: now
|
|
8413
9251
|
};
|
|
8414
9252
|
showId = (await db.insert(shows).values(newShow).returning({ id: shows.id }))[0]?.id ?? 0;
|
|
8415
|
-
|
|
9253
|
+
externalId = metadata?.externalId ?? null;
|
|
8416
9254
|
}
|
|
8417
9255
|
const seasonMap = /* @__PURE__ */ new Map();
|
|
8418
9256
|
for (const episode of episodes$1) {
|
|
@@ -8421,36 +9259,38 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
8421
9259
|
seasonMap.set(episode.seasonNumber, seasonEpisodes);
|
|
8422
9260
|
}
|
|
8423
9261
|
for (const [seasonNumber, seasonEpisodes] of seasonMap) {
|
|
8424
|
-
let
|
|
9262
|
+
let metadataEpisodes = [];
|
|
8425
9263
|
const existingSeasons = await db.select().from(seasons).where(and(eq(seasons.showId, showId), eq(seasons.seasonNumber, seasonNumber))).limit(1);
|
|
8426
9264
|
let seasonId;
|
|
8427
9265
|
if (existingSeasons.length > 0 && existingSeasons[0]) {
|
|
8428
9266
|
seasonId = existingSeasons[0].id;
|
|
8429
|
-
if (
|
|
9267
|
+
if (externalId !== null) metadataEpisodes = (await fetchSeasonMetadata(externalId, seasonNumber))?.episodes ?? [];
|
|
8430
9268
|
} else {
|
|
8431
9269
|
let seasonMetadata = null;
|
|
8432
|
-
if (
|
|
8433
|
-
console.log(` Fetching
|
|
8434
|
-
seasonMetadata = await fetchSeasonMetadata(
|
|
8435
|
-
|
|
9270
|
+
if (externalId !== null) {
|
|
9271
|
+
console.log(` Fetching metadata for season ${seasonNumber} of: ${name}`);
|
|
9272
|
+
seasonMetadata = await fetchSeasonMetadata(externalId, seasonNumber);
|
|
9273
|
+
metadataEpisodes = seasonMetadata?.episodes ?? [];
|
|
8436
9274
|
}
|
|
8437
9275
|
const newSeason = {
|
|
8438
9276
|
showId,
|
|
8439
9277
|
seasonNumber,
|
|
8440
9278
|
name: seasonMetadata?.name ?? null,
|
|
8441
9279
|
overview: seasonMetadata?.overview ?? null,
|
|
8442
|
-
|
|
9280
|
+
posterUrl: seasonMetadata?.posterUrl ?? null,
|
|
8443
9281
|
episodeCount: seasonEpisodes.length
|
|
8444
9282
|
};
|
|
8445
9283
|
seasonId = (await db.insert(seasons).values(newSeason).returning({ id: seasons.id }))[0]?.id ?? 0;
|
|
8446
9284
|
}
|
|
8447
9285
|
for (const scannedEpisode of seasonEpisodes) {
|
|
8448
9286
|
total++;
|
|
8449
|
-
const
|
|
8450
|
-
const episodeTitle =
|
|
9287
|
+
const metadataEpisode = metadataEpisodes.find((e) => e.episodeNumber === scannedEpisode.episodeNumber);
|
|
9288
|
+
const episodeTitle = metadataEpisode?.title ?? scannedEpisode.title ?? `Episode ${scannedEpisode.episodeNumber}`;
|
|
8451
9289
|
const existingEpisodes = await db.select().from(episodes).where(eq(episodes.filePath, scannedEpisode.filePath)).limit(1);
|
|
8452
9290
|
const now = /* @__PURE__ */ new Date();
|
|
8453
|
-
|
|
9291
|
+
let episodeId;
|
|
9292
|
+
if (existingEpisodes.length > 0 && existingEpisodes[0]) {
|
|
9293
|
+
episodeId = existingEpisodes[0].id;
|
|
8454
9294
|
await db.update(episodes).set({
|
|
8455
9295
|
title: episodeTitle,
|
|
8456
9296
|
fileName: scannedEpisode.fileName,
|
|
@@ -8475,14 +9315,14 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
8475
9315
|
fileName: scannedEpisode.fileName,
|
|
8476
9316
|
fileSize: scannedEpisode.fileSize,
|
|
8477
9317
|
extension: scannedEpisode.extension,
|
|
8478
|
-
|
|
8479
|
-
overview:
|
|
8480
|
-
runtime:
|
|
8481
|
-
|
|
9318
|
+
externalId: null,
|
|
9319
|
+
overview: metadataEpisode?.overview ?? null,
|
|
9320
|
+
runtime: metadataEpisode?.runtime ?? null,
|
|
9321
|
+
stillUrl: metadataEpisode?.stillUrl ?? null,
|
|
8482
9322
|
createdAt: now,
|
|
8483
9323
|
updatedAt: now
|
|
8484
9324
|
};
|
|
8485
|
-
await db.insert(episodes).values(newEpisode);
|
|
9325
|
+
episodeId = (await db.insert(episodes).values(newEpisode).returning({ id: episodes.id }))[0]?.id ?? null;
|
|
8486
9326
|
added++;
|
|
8487
9327
|
await onProgress?.({
|
|
8488
9328
|
added,
|
|
@@ -8490,6 +9330,10 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
8490
9330
|
updated
|
|
8491
9331
|
});
|
|
8492
9332
|
}
|
|
9333
|
+
if (episodeId !== null) {
|
|
9334
|
+
const discoveredSubtitles = discoverSubtitlesForVideo(scannedEpisode.filePath, scannedEpisode.subtitleSiblings);
|
|
9335
|
+
await syncSubtitlesForEpisode(episodeId, discoveredSubtitles);
|
|
9336
|
+
}
|
|
8493
9337
|
}
|
|
8494
9338
|
}
|
|
8495
9339
|
console.log(` Found show: ${name} (${episodes$1.length} episodes)`);
|
|
@@ -8501,6 +9345,178 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
8501
9345
|
};
|
|
8502
9346
|
}
|
|
8503
9347
|
//#endregion
|
|
9348
|
+
//#region src/services/scan-manager.ts
|
|
9349
|
+
const interruptedMessage = "Server restarted mid-scan. Run a manual scan to resume.";
|
|
9350
|
+
function createScanManager(dependencies = {}) {
|
|
9351
|
+
const database = dependencies.database ?? db;
|
|
9352
|
+
const scanLibrary$1 = dependencies.scanLibrary ?? scanLibrary;
|
|
9353
|
+
const scanShowLibrary$1 = dependencies.scanShowLibrary ?? scanShowLibrary;
|
|
9354
|
+
const emitter = new EventEmitter();
|
|
9355
|
+
emitter.setMaxListeners(0);
|
|
9356
|
+
let currentJob = null;
|
|
9357
|
+
async function getStatus() {
|
|
9358
|
+
if (currentJob) {
|
|
9359
|
+
const [latest] = await database.select().from(scanJobs).where(eq(scanJobs.id, currentJob.id)).limit(1);
|
|
9360
|
+
return {
|
|
9361
|
+
currentJob: latest ?? currentJob,
|
|
9362
|
+
isRunning: true,
|
|
9363
|
+
lastJob: latest ?? currentJob
|
|
9364
|
+
};
|
|
9365
|
+
}
|
|
9366
|
+
const [last] = await database.select().from(scanJobs).orderBy(desc(scanJobs.startedAt)).limit(1);
|
|
9367
|
+
return {
|
|
9368
|
+
currentJob: null,
|
|
9369
|
+
isRunning: false,
|
|
9370
|
+
lastJob: last ?? null
|
|
9371
|
+
};
|
|
9372
|
+
}
|
|
9373
|
+
function isRunning() {
|
|
9374
|
+
return currentJob !== null;
|
|
9375
|
+
}
|
|
9376
|
+
async function recoverInterruptedJobs() {
|
|
9377
|
+
const now = /* @__PURE__ */ new Date();
|
|
9378
|
+
const running = await database.select().from(scanJobs).where(eq(scanJobs.status, "running"));
|
|
9379
|
+
if (running.length === 0) return;
|
|
9380
|
+
for (const job of running) await database.update(scanJobs).set({
|
|
9381
|
+
endedAt: now,
|
|
9382
|
+
errorMessage: interruptedMessage,
|
|
9383
|
+
status: "error"
|
|
9384
|
+
}).where(eq(scanJobs.id, job.id));
|
|
9385
|
+
log.info(`Marked ${running.length} interrupted scan job(s) as failed after a restart.`);
|
|
9386
|
+
}
|
|
9387
|
+
async function start() {
|
|
9388
|
+
if (currentJob) return { status: "already-running" };
|
|
9389
|
+
const libraries$4 = await database.select().from(libraries);
|
|
9390
|
+
if (libraries$4.length === 0) return {
|
|
9391
|
+
status: "no-libraries",
|
|
9392
|
+
message: "No libraries configured. Add a library in Settings before scanning."
|
|
9393
|
+
};
|
|
9394
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
9395
|
+
const [inserted] = await database.insert(scanJobs).values({
|
|
9396
|
+
startedAt,
|
|
9397
|
+
status: "running",
|
|
9398
|
+
added: 0,
|
|
9399
|
+
updated: 0,
|
|
9400
|
+
total: 0
|
|
9401
|
+
}).returning();
|
|
9402
|
+
invariant(inserted, "Failed to create scan_jobs row.");
|
|
9403
|
+
currentJob = inserted;
|
|
9404
|
+
emitter.emit("job-started", {
|
|
9405
|
+
jobId: inserted.id,
|
|
9406
|
+
startedAt: inserted.startedAt
|
|
9407
|
+
});
|
|
9408
|
+
let totalAdded = 0;
|
|
9409
|
+
let totalUpdated = 0;
|
|
9410
|
+
let totalFound = 0;
|
|
9411
|
+
const libraryErrors = [];
|
|
9412
|
+
try {
|
|
9413
|
+
for (let index = 0; index < libraries$4.length; index++) {
|
|
9414
|
+
const library = libraries$4[index];
|
|
9415
|
+
if (!library) continue;
|
|
9416
|
+
const libraryType = library.type === "shows" ? "shows" : "movies";
|
|
9417
|
+
emitter.emit("library-start", {
|
|
9418
|
+
index,
|
|
9419
|
+
libraryId: library.id,
|
|
9420
|
+
name: library.name,
|
|
9421
|
+
type: libraryType
|
|
9422
|
+
});
|
|
9423
|
+
const onProgress = async (progress) => {
|
|
9424
|
+
const aggregate = {
|
|
9425
|
+
added: totalAdded + progress.added,
|
|
9426
|
+
total: totalFound + progress.total,
|
|
9427
|
+
updated: totalUpdated + progress.updated
|
|
9428
|
+
};
|
|
9429
|
+
await database.update(scanJobs).set(aggregate).where(eq(scanJobs.id, inserted.id));
|
|
9430
|
+
emitter.emit("file-scanned", {
|
|
9431
|
+
...progress,
|
|
9432
|
+
index,
|
|
9433
|
+
libraryId: library.id
|
|
9434
|
+
});
|
|
9435
|
+
};
|
|
9436
|
+
try {
|
|
9437
|
+
const result = libraryType === "shows" ? await scanShowLibrary$1(library.path, onProgress) : await scanLibrary$1(library.path, onProgress);
|
|
9438
|
+
totalAdded += result.added;
|
|
9439
|
+
totalUpdated += result.updated;
|
|
9440
|
+
totalFound += result.total;
|
|
9441
|
+
emitter.emit("library-complete", {
|
|
9442
|
+
...result,
|
|
9443
|
+
index,
|
|
9444
|
+
libraryId: library.id
|
|
9445
|
+
});
|
|
9446
|
+
} catch (caughtError) {
|
|
9447
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9448
|
+
libraryErrors.push(`${library.name} — ${message}`);
|
|
9449
|
+
emitter.emit("library-error", {
|
|
9450
|
+
error: message,
|
|
9451
|
+
index,
|
|
9452
|
+
libraryId: library.id
|
|
9453
|
+
});
|
|
9454
|
+
}
|
|
9455
|
+
}
|
|
9456
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
9457
|
+
const status = libraryErrors.length > 0 ? "error" : "done";
|
|
9458
|
+
const errorMessage = libraryErrors.length > 0 ? libraryErrors.join("; ") : null;
|
|
9459
|
+
await database.update(scanJobs).set({
|
|
9460
|
+
added: totalAdded,
|
|
9461
|
+
endedAt,
|
|
9462
|
+
errorMessage,
|
|
9463
|
+
status,
|
|
9464
|
+
total: totalFound,
|
|
9465
|
+
updated: totalUpdated
|
|
9466
|
+
}).where(eq(scanJobs.id, inserted.id));
|
|
9467
|
+
const payload = {
|
|
9468
|
+
added: totalAdded,
|
|
9469
|
+
errorMessage,
|
|
9470
|
+
jobId: inserted.id,
|
|
9471
|
+
status,
|
|
9472
|
+
total: totalFound,
|
|
9473
|
+
updated: totalUpdated
|
|
9474
|
+
};
|
|
9475
|
+
emitter.emit("job-completed", payload);
|
|
9476
|
+
return {
|
|
9477
|
+
added: totalAdded,
|
|
9478
|
+
errorMessage: errorMessage ?? void 0,
|
|
9479
|
+
status,
|
|
9480
|
+
total: totalFound,
|
|
9481
|
+
updated: totalUpdated
|
|
9482
|
+
};
|
|
9483
|
+
} catch (caughtError) {
|
|
9484
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
9485
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9486
|
+
await database.update(scanJobs).set({
|
|
9487
|
+
added: totalAdded,
|
|
9488
|
+
endedAt,
|
|
9489
|
+
errorMessage: message,
|
|
9490
|
+
status: "error",
|
|
9491
|
+
total: totalFound,
|
|
9492
|
+
updated: totalUpdated
|
|
9493
|
+
}).where(eq(scanJobs.id, inserted.id));
|
|
9494
|
+
emitter.emit("job-failed", {
|
|
9495
|
+
error: message,
|
|
9496
|
+
jobId: inserted.id
|
|
9497
|
+
});
|
|
9498
|
+
return {
|
|
9499
|
+
added: totalAdded,
|
|
9500
|
+
errorMessage: message,
|
|
9501
|
+
status: "error",
|
|
9502
|
+
total: totalFound,
|
|
9503
|
+
updated: totalUpdated
|
|
9504
|
+
};
|
|
9505
|
+
} finally {
|
|
9506
|
+
currentJob = null;
|
|
9507
|
+
}
|
|
9508
|
+
}
|
|
9509
|
+
return {
|
|
9510
|
+
getStatus,
|
|
9511
|
+
isRunning,
|
|
9512
|
+
off: (event, listener) => emitter.off(event, listener),
|
|
9513
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
9514
|
+
recoverInterruptedJobs,
|
|
9515
|
+
start
|
|
9516
|
+
};
|
|
9517
|
+
}
|
|
9518
|
+
const scanManager = createScanManager();
|
|
9519
|
+
//#endregion
|
|
8504
9520
|
//#region src/api/routes/scan.ts
|
|
8505
9521
|
const scanRoutes = new Hono();
|
|
8506
9522
|
scanRoutes.get("/libraries", async (context) => {
|
|
@@ -8512,74 +9528,571 @@ scanRoutes.get("/libraries", async (context) => {
|
|
|
8512
9528
|
type: library.type
|
|
8513
9529
|
})));
|
|
8514
9530
|
});
|
|
8515
|
-
scanRoutes.get("/
|
|
8516
|
-
const
|
|
8517
|
-
|
|
9531
|
+
scanRoutes.get("/status", async (context) => {
|
|
9532
|
+
const status = await scanManager.getStatus();
|
|
9533
|
+
return context.json({
|
|
9534
|
+
currentJob: status.currentJob ? {
|
|
9535
|
+
added: status.currentJob.added,
|
|
9536
|
+
endedAt: status.currentJob.endedAt?.toISOString() ?? null,
|
|
9537
|
+
errorMessage: status.currentJob.errorMessage,
|
|
9538
|
+
id: status.currentJob.id,
|
|
9539
|
+
startedAt: status.currentJob.startedAt.toISOString(),
|
|
9540
|
+
status: status.currentJob.status,
|
|
9541
|
+
total: status.currentJob.total,
|
|
9542
|
+
updated: status.currentJob.updated
|
|
9543
|
+
} : null,
|
|
9544
|
+
isRunning: status.isRunning,
|
|
9545
|
+
lastJob: status.lastJob ? {
|
|
9546
|
+
added: status.lastJob.added,
|
|
9547
|
+
endedAt: status.lastJob.endedAt?.toISOString() ?? null,
|
|
9548
|
+
errorMessage: status.lastJob.errorMessage,
|
|
9549
|
+
id: status.lastJob.id,
|
|
9550
|
+
startedAt: status.lastJob.startedAt.toISOString(),
|
|
9551
|
+
status: status.lastJob.status,
|
|
9552
|
+
total: status.lastJob.total,
|
|
9553
|
+
updated: status.lastJob.updated
|
|
9554
|
+
} : null
|
|
9555
|
+
});
|
|
9556
|
+
});
|
|
9557
|
+
scanRoutes.post("/start", async (context) => {
|
|
9558
|
+
if ((await db.select().from(libraries)).length === 0) return context.json({ error: { message: "No libraries configured. Add a library in Settings before scanning." } }, 400);
|
|
9559
|
+
if (scanManager.isRunning()) return context.json({ status: "already-running" });
|
|
9560
|
+
scanManager.start().catch((caughtError) => {
|
|
9561
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9562
|
+
console.error(`Manual scan failed: ${message}`);
|
|
9563
|
+
});
|
|
9564
|
+
return context.json({ status: "started" });
|
|
9565
|
+
});
|
|
9566
|
+
scanRoutes.get("/stream", (context) => {
|
|
8518
9567
|
return streamSSE(context, async (stream) => {
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
let totalFound = 0;
|
|
8522
|
-
for (let i = 0; i < libraries$2.length; i++) {
|
|
8523
|
-
const library = libraries$2[i];
|
|
8524
|
-
if (!library) continue;
|
|
8525
|
-
await stream.writeSSE({
|
|
9568
|
+
const onLibraryStart = (event) => {
|
|
9569
|
+
stream.writeSSE({
|
|
8526
9570
|
event: "library-start",
|
|
8527
|
-
data: JSON.stringify(
|
|
9571
|
+
data: JSON.stringify(event)
|
|
8528
9572
|
});
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
8538
|
-
|
|
8539
|
-
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8549
|
-
|
|
8550
|
-
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
|
|
8554
|
-
|
|
9573
|
+
};
|
|
9574
|
+
const onFileScanned = (event) => {
|
|
9575
|
+
stream.writeSSE({
|
|
9576
|
+
event: "file-scanned",
|
|
9577
|
+
data: JSON.stringify(event)
|
|
9578
|
+
});
|
|
9579
|
+
};
|
|
9580
|
+
const onLibraryComplete = (event) => {
|
|
9581
|
+
stream.writeSSE({
|
|
9582
|
+
event: "library-complete",
|
|
9583
|
+
data: JSON.stringify(event)
|
|
9584
|
+
});
|
|
9585
|
+
};
|
|
9586
|
+
const onLibraryError = (event) => {
|
|
9587
|
+
stream.writeSSE({
|
|
9588
|
+
event: "library-error",
|
|
9589
|
+
data: JSON.stringify(event)
|
|
9590
|
+
});
|
|
9591
|
+
};
|
|
9592
|
+
const onScanComplete = (event) => {
|
|
9593
|
+
stream.writeSSE({
|
|
9594
|
+
event: "scan-complete",
|
|
9595
|
+
data: JSON.stringify({
|
|
9596
|
+
added: event.added,
|
|
9597
|
+
errorMessage: event.errorMessage,
|
|
9598
|
+
found: event.total,
|
|
9599
|
+
status: event.status,
|
|
9600
|
+
updated: event.updated
|
|
9601
|
+
})
|
|
9602
|
+
});
|
|
9603
|
+
};
|
|
9604
|
+
scanManager.on("library-start", onLibraryStart);
|
|
9605
|
+
scanManager.on("file-scanned", onFileScanned);
|
|
9606
|
+
scanManager.on("library-complete", onLibraryComplete);
|
|
9607
|
+
scanManager.on("library-error", onLibraryError);
|
|
9608
|
+
scanManager.on("job-completed", onScanComplete);
|
|
9609
|
+
await stream.writeSSE({
|
|
9610
|
+
event: "ready",
|
|
9611
|
+
data: JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString() })
|
|
9612
|
+
});
|
|
9613
|
+
stream.onAbort(() => {
|
|
9614
|
+
scanManager.off("library-start", onLibraryStart);
|
|
9615
|
+
scanManager.off("file-scanned", onFileScanned);
|
|
9616
|
+
scanManager.off("library-complete", onLibraryComplete);
|
|
9617
|
+
scanManager.off("library-error", onLibraryError);
|
|
9618
|
+
scanManager.off("job-completed", onScanComplete);
|
|
9619
|
+
});
|
|
9620
|
+
const keepAliveMs = 15e3;
|
|
9621
|
+
try {
|
|
9622
|
+
while (!stream.aborted && !stream.closed) {
|
|
9623
|
+
await stream.sleep(keepAliveMs);
|
|
9624
|
+
if (stream.aborted || stream.closed) break;
|
|
8555
9625
|
await stream.writeSSE({
|
|
8556
|
-
event: "
|
|
8557
|
-
data:
|
|
8558
|
-
error: error instanceof Error ? error.message : "Unknown error",
|
|
8559
|
-
index: i
|
|
8560
|
-
})
|
|
9626
|
+
event: "ping",
|
|
9627
|
+
data: "{}"
|
|
8561
9628
|
});
|
|
8562
9629
|
}
|
|
9630
|
+
} catch {}
|
|
9631
|
+
});
|
|
9632
|
+
});
|
|
9633
|
+
//#endregion
|
|
9634
|
+
//#region src/services/fts-query-parser.ts
|
|
9635
|
+
/**
|
|
9636
|
+
* Convert raw user input into a safe SQLite FTS5 MATCH expression.
|
|
9637
|
+
*
|
|
9638
|
+
* FTS5 has a query syntax with operators (AND, OR, NEAR, NOT), prefix
|
|
9639
|
+
* wildcards (*), column filters (title:foo), and quoted phrases. We don't
|
|
9640
|
+
* want users to accidentally trigger any of that — and we don't want
|
|
9641
|
+
* adversarial input to crash the parser. So we:
|
|
9642
|
+
*
|
|
9643
|
+
* 1. Split the input on whitespace.
|
|
9644
|
+
* 2. Strip every character except letters, digits, and the underscore
|
|
9645
|
+
* from each token. This drops quotes, asterisks, parens, colons,
|
|
9646
|
+
* and operator-like punctuation.
|
|
9647
|
+
* 3. Wrap each surviving token in double quotes (so reserved words like
|
|
9648
|
+
* `OR` or `NEAR` are treated as literal terms) and append `*` for
|
|
9649
|
+
* prefix matching.
|
|
9650
|
+
* 4. Join the tokens with spaces. FTS5 implicitly ANDs them.
|
|
9651
|
+
*
|
|
9652
|
+
* Returns null when there is nothing searchable (empty input, only
|
|
9653
|
+
* whitespace, or only stripped characters).
|
|
9654
|
+
*/
|
|
9655
|
+
function buildFtsMatchQuery(rawInput) {
|
|
9656
|
+
const tokens = rawInput.split(/\s+/).map((token) => sanitizeToken(token)).filter((token) => token.length > 0);
|
|
9657
|
+
if (tokens.length === 0) return null;
|
|
9658
|
+
return tokens.map((token) => `"${token}"*`).join(" ");
|
|
9659
|
+
}
|
|
9660
|
+
function sanitizeToken(token) {
|
|
9661
|
+
return token.replace(/[^\p{L}\p{N}_]+/gu, "");
|
|
9662
|
+
}
|
|
9663
|
+
//#endregion
|
|
9664
|
+
//#region src/api/routes/search.ts
|
|
9665
|
+
const defaultLimit = 20;
|
|
9666
|
+
const maxLimit = 50;
|
|
9667
|
+
const maxQueryLength = 256;
|
|
9668
|
+
const searchRoutes = new Hono();
|
|
9669
|
+
searchRoutes.get("/", (context) => {
|
|
9670
|
+
const queryParameter = context.req.query("q");
|
|
9671
|
+
if (queryParameter === void 0) return context.json({ error: { message: "Add a `q` query parameter to search. Example: /api/search?q=dune" } }, 400);
|
|
9672
|
+
if (queryParameter.length > maxQueryLength) return context.json({ error: { message: "Search query is too long. Shorten it to under 256 characters." } }, 400);
|
|
9673
|
+
const limit = parseLimit(context.req.query("limit"));
|
|
9674
|
+
if (limit === null) return context.json({ error: { message: "`limit` must be a positive integer between 1 and 50." } }, 400);
|
|
9675
|
+
const matchExpression = buildFtsMatchQuery(queryParameter);
|
|
9676
|
+
if (matchExpression === null) return context.json({
|
|
9677
|
+
episodes: [],
|
|
9678
|
+
indexEmpty: false,
|
|
9679
|
+
movies: [],
|
|
9680
|
+
shows: []
|
|
9681
|
+
});
|
|
9682
|
+
try {
|
|
9683
|
+
const movies = searchMovies(matchExpression, limit);
|
|
9684
|
+
const shows = searchShows(matchExpression, limit);
|
|
9685
|
+
const episodes = searchEpisodes(matchExpression, limit);
|
|
9686
|
+
const response = {
|
|
9687
|
+
episodes,
|
|
9688
|
+
indexEmpty: movies.length === 0 && shows.length === 0 && episodes.length === 0 && isLibraryEmpty(),
|
|
9689
|
+
movies,
|
|
9690
|
+
shows
|
|
9691
|
+
};
|
|
9692
|
+
return context.json(response);
|
|
9693
|
+
} catch (error) {
|
|
9694
|
+
log.error("Search query failed.", error);
|
|
9695
|
+
return context.json({ error: { message: "Your search couldn't be parsed. Try simpler terms or fewer special characters." } }, 400);
|
|
9696
|
+
}
|
|
9697
|
+
});
|
|
9698
|
+
function isLibraryEmpty() {
|
|
9699
|
+
const [movieCountRow] = db.all(sql`
|
|
9700
|
+
SELECT COUNT(*) AS count FROM movies
|
|
9701
|
+
`);
|
|
9702
|
+
const [showCountRow] = db.all(sql`
|
|
9703
|
+
SELECT COUNT(*) AS count FROM shows
|
|
9704
|
+
`);
|
|
9705
|
+
const [episodeCountRow] = db.all(sql`
|
|
9706
|
+
SELECT COUNT(*) AS count FROM episodes
|
|
9707
|
+
`);
|
|
9708
|
+
return (movieCountRow?.count ?? 0) + (showCountRow?.count ?? 0) + (episodeCountRow?.count ?? 0) === 0;
|
|
9709
|
+
}
|
|
9710
|
+
function parseLimit(raw) {
|
|
9711
|
+
if (raw === void 0 || raw.trim() === "") return defaultLimit;
|
|
9712
|
+
const parsed = Number(raw);
|
|
9713
|
+
if (!Number.isInteger(parsed) || parsed < 1) return null;
|
|
9714
|
+
return Math.min(parsed, maxLimit);
|
|
9715
|
+
}
|
|
9716
|
+
function searchMovies(matchExpression, limit) {
|
|
9717
|
+
return db.all(sql`
|
|
9718
|
+
SELECT
|
|
9719
|
+
movies.id AS id,
|
|
9720
|
+
movies.title AS title,
|
|
9721
|
+
movies.year AS year,
|
|
9722
|
+
movies.overview AS overview,
|
|
9723
|
+
movies.poster_url AS poster_url,
|
|
9724
|
+
movies.backdrop_url AS backdrop_url
|
|
9725
|
+
FROM movies_fts
|
|
9726
|
+
JOIN movies ON movies.rowid = movies_fts.rowid
|
|
9727
|
+
WHERE movies_fts MATCH ${matchExpression}
|
|
9728
|
+
ORDER BY bm25(movies_fts) ASC
|
|
9729
|
+
LIMIT ${limit}
|
|
9730
|
+
`).map((row) => ({
|
|
9731
|
+
backdropUrl: row.backdrop_url,
|
|
9732
|
+
id: row.id,
|
|
9733
|
+
overview: row.overview,
|
|
9734
|
+
posterUrl: row.poster_url,
|
|
9735
|
+
title: row.title,
|
|
9736
|
+
year: row.year
|
|
9737
|
+
}));
|
|
9738
|
+
}
|
|
9739
|
+
function searchShows(matchExpression, limit) {
|
|
9740
|
+
return db.all(sql`
|
|
9741
|
+
SELECT
|
|
9742
|
+
shows.id AS id,
|
|
9743
|
+
shows.title AS title,
|
|
9744
|
+
shows.year AS year,
|
|
9745
|
+
shows.overview AS overview,
|
|
9746
|
+
shows.poster_url AS poster_url,
|
|
9747
|
+
shows.backdrop_url AS backdrop_url
|
|
9748
|
+
FROM shows_fts
|
|
9749
|
+
JOIN shows ON shows.rowid = shows_fts.rowid
|
|
9750
|
+
WHERE shows_fts MATCH ${matchExpression}
|
|
9751
|
+
ORDER BY bm25(shows_fts) ASC
|
|
9752
|
+
LIMIT ${limit}
|
|
9753
|
+
`).map((row) => ({
|
|
9754
|
+
backdropUrl: row.backdrop_url,
|
|
9755
|
+
id: row.id,
|
|
9756
|
+
overview: row.overview,
|
|
9757
|
+
posterUrl: row.poster_url,
|
|
9758
|
+
title: row.title,
|
|
9759
|
+
year: row.year
|
|
9760
|
+
}));
|
|
9761
|
+
}
|
|
9762
|
+
function searchEpisodes(matchExpression, limit) {
|
|
9763
|
+
return db.all(sql`
|
|
9764
|
+
SELECT
|
|
9765
|
+
episodes.id AS id,
|
|
9766
|
+
episodes.show_id AS show_id,
|
|
9767
|
+
episodes.season_number AS season_number,
|
|
9768
|
+
episodes.episode_number AS episode_number,
|
|
9769
|
+
episodes.title AS title,
|
|
9770
|
+
episodes.overview AS overview,
|
|
9771
|
+
episodes.still_url AS still_url,
|
|
9772
|
+
shows.title AS show_title
|
|
9773
|
+
FROM episodes_fts
|
|
9774
|
+
JOIN episodes ON episodes.rowid = episodes_fts.rowid
|
|
9775
|
+
LEFT JOIN shows ON shows.id = episodes.show_id
|
|
9776
|
+
WHERE episodes_fts MATCH ${matchExpression}
|
|
9777
|
+
ORDER BY bm25(episodes_fts) ASC
|
|
9778
|
+
LIMIT ${limit}
|
|
9779
|
+
`).map((row) => ({
|
|
9780
|
+
episodeNumber: row.episode_number,
|
|
9781
|
+
id: row.id,
|
|
9782
|
+
overview: row.overview,
|
|
9783
|
+
seasonNumber: row.season_number,
|
|
9784
|
+
showId: row.show_id,
|
|
9785
|
+
showTitle: row.show_title ?? "",
|
|
9786
|
+
stillUrl: row.still_url,
|
|
9787
|
+
title: row.title
|
|
9788
|
+
}));
|
|
9789
|
+
}
|
|
9790
|
+
//#endregion
|
|
9791
|
+
//#region src/services/settings.ts
|
|
9792
|
+
const scanScheduleSettingKey = "scanSchedule";
|
|
9793
|
+
const scanScheduleValues = [
|
|
9794
|
+
"off",
|
|
9795
|
+
"6h",
|
|
9796
|
+
"12h",
|
|
9797
|
+
"24h"
|
|
9798
|
+
];
|
|
9799
|
+
function isScanScheduleValue(value) {
|
|
9800
|
+
return scanScheduleValues.includes(value);
|
|
9801
|
+
}
|
|
9802
|
+
/**
|
|
9803
|
+
* Read a setting value from the DB. Returns null when the key is not set.
|
|
9804
|
+
*
|
|
9805
|
+
* Accepts an optional database instance so tests and scripts can pass an
|
|
9806
|
+
* in-memory SQLite instance instead of the singleton.
|
|
9807
|
+
*/
|
|
9808
|
+
async function getSetting(key, database = db) {
|
|
9809
|
+
const [row] = await database.select().from(settings).where(eq(settings.key, key)).limit(1);
|
|
9810
|
+
return row?.value ?? null;
|
|
9811
|
+
}
|
|
9812
|
+
/**
|
|
9813
|
+
* Upsert a setting value.
|
|
9814
|
+
*
|
|
9815
|
+
* Uses a single INSERT ... ON CONFLICT so two concurrent callers don't both
|
|
9816
|
+
* read null, both INSERT, and fail with a UNIQUE constraint on the second.
|
|
9817
|
+
*/
|
|
9818
|
+
async function setSetting(key, value, database = db) {
|
|
9819
|
+
const now = Date.now();
|
|
9820
|
+
await database.insert(settings).values({
|
|
9821
|
+
key,
|
|
9822
|
+
value,
|
|
9823
|
+
updatedAt: now
|
|
9824
|
+
}).onConflictDoUpdate({
|
|
9825
|
+
target: settings.key,
|
|
9826
|
+
set: {
|
|
9827
|
+
value,
|
|
9828
|
+
updatedAt: now
|
|
8563
9829
|
}
|
|
8564
|
-
await stream.writeSSE({
|
|
8565
|
-
event: "scan-complete",
|
|
8566
|
-
data: JSON.stringify({
|
|
8567
|
-
added: totalAdded,
|
|
8568
|
-
found: totalFound,
|
|
8569
|
-
updated: totalUpdated
|
|
8570
|
-
})
|
|
8571
|
-
});
|
|
8572
9830
|
});
|
|
9831
|
+
}
|
|
9832
|
+
//#endregion
|
|
9833
|
+
//#region src/services/scheduler.ts
|
|
9834
|
+
const millisecondsPerHour = 3600 * 1e3;
|
|
9835
|
+
function scheduleIntervalMilliseconds(value) {
|
|
9836
|
+
switch (value) {
|
|
9837
|
+
case "off": return null;
|
|
9838
|
+
case "6h": return 6 * millisecondsPerHour;
|
|
9839
|
+
case "12h": return 12 * millisecondsPerHour;
|
|
9840
|
+
case "24h": return 24 * millisecondsPerHour;
|
|
9841
|
+
}
|
|
9842
|
+
}
|
|
9843
|
+
async function defaultGetSchedule() {
|
|
9844
|
+
const stored = await getSetting(scanScheduleSettingKey);
|
|
9845
|
+
if (stored === null) return "off";
|
|
9846
|
+
if (isScanScheduleValue(stored)) return stored;
|
|
9847
|
+
log.warn(`Unknown scanSchedule value "${stored}" in settings. Falling back to "off".`);
|
|
9848
|
+
return "off";
|
|
9849
|
+
}
|
|
9850
|
+
function createScheduler(dependencies = {}) {
|
|
9851
|
+
const getSchedule = dependencies.getSchedule ?? defaultGetSchedule;
|
|
9852
|
+
const scanManager$1 = dependencies.scanManager ?? {
|
|
9853
|
+
isRunning: () => scanManager.isRunning(),
|
|
9854
|
+
start: () => scanManager.start()
|
|
9855
|
+
};
|
|
9856
|
+
let currentSchedule = "off";
|
|
9857
|
+
let nextRunAt = null;
|
|
9858
|
+
let timer = null;
|
|
9859
|
+
function clearTimer() {
|
|
9860
|
+
if (timer !== null) {
|
|
9861
|
+
clearTimeout(timer);
|
|
9862
|
+
timer = null;
|
|
9863
|
+
}
|
|
9864
|
+
}
|
|
9865
|
+
function scheduleNext(intervalMs) {
|
|
9866
|
+
clearTimer();
|
|
9867
|
+
nextRunAt = new Date(Date.now() + intervalMs);
|
|
9868
|
+
timer = setTimeout(() => {
|
|
9869
|
+
tick();
|
|
9870
|
+
}, intervalMs);
|
|
9871
|
+
}
|
|
9872
|
+
async function tick() {
|
|
9873
|
+
if (scanManager$1.isRunning()) log.debug("Scheduler tick skipped — a scan is already running.");
|
|
9874
|
+
else try {
|
|
9875
|
+
const result = await scanManager$1.start();
|
|
9876
|
+
if (result.status === "error") {
|
|
9877
|
+
const message = "errorMessage" in result && result.errorMessage ? `: ${result.errorMessage}` : ".";
|
|
9878
|
+
log.warn(`Scheduled scan finished with errors${message}`);
|
|
9879
|
+
} else if (result.status === "no-libraries") log.debug("Scheduled scan skipped — no libraries configured.");
|
|
9880
|
+
} catch (caughtError) {
|
|
9881
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9882
|
+
log.warn(`Scheduled scan failed to start: ${message}. Check the scan page.`);
|
|
9883
|
+
}
|
|
9884
|
+
const activeInterval = scheduleIntervalMilliseconds(currentSchedule);
|
|
9885
|
+
if (activeInterval === null) {
|
|
9886
|
+
nextRunAt = null;
|
|
9887
|
+
return;
|
|
9888
|
+
}
|
|
9889
|
+
scheduleNext(activeInterval);
|
|
9890
|
+
}
|
|
9891
|
+
function applySchedule(value) {
|
|
9892
|
+
currentSchedule = value;
|
|
9893
|
+
const intervalMs = scheduleIntervalMilliseconds(value);
|
|
9894
|
+
if (intervalMs === null) {
|
|
9895
|
+
clearTimer();
|
|
9896
|
+
nextRunAt = null;
|
|
9897
|
+
log.info("Scan scheduler is off.");
|
|
9898
|
+
return;
|
|
9899
|
+
}
|
|
9900
|
+
scheduleNext(intervalMs);
|
|
9901
|
+
log.info(`Next scheduled scan at ${nextRunAt?.toISOString() ?? "unknown"} (${value}).`);
|
|
9902
|
+
}
|
|
9903
|
+
return {
|
|
9904
|
+
getInfo: () => ({
|
|
9905
|
+
nextRunAt,
|
|
9906
|
+
schedule: currentSchedule
|
|
9907
|
+
}),
|
|
9908
|
+
start: async () => {
|
|
9909
|
+
applySchedule(await getSchedule());
|
|
9910
|
+
},
|
|
9911
|
+
stop: () => {
|
|
9912
|
+
clearTimer();
|
|
9913
|
+
currentSchedule = "off";
|
|
9914
|
+
nextRunAt = null;
|
|
9915
|
+
},
|
|
9916
|
+
updateSchedule: (value) => {
|
|
9917
|
+
applySchedule(value);
|
|
9918
|
+
}
|
|
9919
|
+
};
|
|
9920
|
+
}
|
|
9921
|
+
const scheduler = createScheduler();
|
|
9922
|
+
//#endregion
|
|
9923
|
+
//#region src/api/routes/settings.ts
|
|
9924
|
+
const reservedKeys = new Set([scanScheduleSettingKey]);
|
|
9925
|
+
const reservedKeyRoute = { [scanScheduleSettingKey]: "/api/settings/scan-schedule" };
|
|
9926
|
+
async function pathIsReadable(path) {
|
|
9927
|
+
try {
|
|
9928
|
+
await access(path, constants.R_OK);
|
|
9929
|
+
return true;
|
|
9930
|
+
} catch {
|
|
9931
|
+
return false;
|
|
9932
|
+
}
|
|
9933
|
+
}
|
|
9934
|
+
function validateLibraryInput(input) {
|
|
9935
|
+
if (typeof input !== "object" || input === null) return "Library entry must be an object.";
|
|
9936
|
+
const record = input;
|
|
9937
|
+
const name = typeof record.name === "string" ? record.name.trim() : "";
|
|
9938
|
+
const libraryPath = typeof record.path === "string" ? record.path.trim() : "";
|
|
9939
|
+
const type = record.type;
|
|
9940
|
+
if (libraryPath.length === 0) return "Library path is required.";
|
|
9941
|
+
if (type !== "movies" && type !== "shows") return "Library type must be \"movies\" or \"shows\".";
|
|
9942
|
+
return {
|
|
9943
|
+
name,
|
|
9944
|
+
path: libraryPath,
|
|
9945
|
+
type
|
|
9946
|
+
};
|
|
9947
|
+
}
|
|
9948
|
+
/**
|
|
9949
|
+
* Build the LIKE pattern that matches any file path under the given library
|
|
9950
|
+
* root. Escapes SQL LIKE wildcards so a path containing `%` or `_` doesn't
|
|
9951
|
+
* accidentally match siblings.
|
|
9952
|
+
*/
|
|
9953
|
+
function libraryPathPrefixPattern(libraryPath) {
|
|
9954
|
+
const resolved = path.resolve(libraryPath);
|
|
9955
|
+
return `${(resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`).replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`;
|
|
9956
|
+
}
|
|
9957
|
+
const settingsRoutes = new Hono();
|
|
9958
|
+
settingsRoutes.get("/libraries", async (context) => {
|
|
9959
|
+
const libraries$2 = await db.select().from(libraries);
|
|
9960
|
+
return context.json(libraries$2.map((library) => ({
|
|
9961
|
+
id: library.id,
|
|
9962
|
+
name: library.name,
|
|
9963
|
+
path: library.path,
|
|
9964
|
+
type: library.type
|
|
9965
|
+
})));
|
|
9966
|
+
});
|
|
9967
|
+
settingsRoutes.post("/libraries", async (context) => {
|
|
9968
|
+
let body;
|
|
9969
|
+
try {
|
|
9970
|
+
body = await context.req.json();
|
|
9971
|
+
} catch {
|
|
9972
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
9973
|
+
}
|
|
9974
|
+
const parsed = validateLibraryInput(body);
|
|
9975
|
+
if (typeof parsed === "string") return context.json({ error: { message: parsed } }, 400);
|
|
9976
|
+
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);
|
|
9977
|
+
const [existing] = await db.select().from(libraries).where(eq(libraries.path, parsed.path)).limit(1);
|
|
9978
|
+
if (existing) return context.json({ error: { message: `A library at ${parsed.path} already exists.` } }, 400);
|
|
9979
|
+
const resolvedName = parsed.name.length > 0 ? parsed.name : parsed.path.split(/[\\/]/).filter(Boolean).pop() ?? parsed.type;
|
|
9980
|
+
const [created] = await db.insert(libraries).values({
|
|
9981
|
+
name: resolvedName,
|
|
9982
|
+
path: parsed.path,
|
|
9983
|
+
type: parsed.type,
|
|
9984
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
9985
|
+
}).returning();
|
|
9986
|
+
if (!created) return context.json({ error: { message: "Failed to create library." } }, 500);
|
|
9987
|
+
return context.json({
|
|
9988
|
+
id: created.id,
|
|
9989
|
+
name: created.name,
|
|
9990
|
+
path: created.path,
|
|
9991
|
+
type: created.type
|
|
9992
|
+
}, 201);
|
|
9993
|
+
});
|
|
9994
|
+
settingsRoutes.delete("/libraries/:id", async (context) => {
|
|
9995
|
+
const id = Number.parseInt(context.req.param("id"), 10);
|
|
9996
|
+
if (!Number.isFinite(id)) return context.json({ error: { message: "Invalid library id." } }, 400);
|
|
9997
|
+
const [existing] = await db.select().from(libraries).where(eq(libraries.id, id)).limit(1);
|
|
9998
|
+
if (!existing) return context.json({ error: { message: "Library not found." } }, 404);
|
|
9999
|
+
const force = new URL(context.req.url).searchParams.get("force") === "true";
|
|
10000
|
+
const pattern = libraryPathPrefixPattern(existing.path);
|
|
10001
|
+
if (!force) {
|
|
10002
|
+
const referenceCount = countLibraryReferences(existing, pattern);
|
|
10003
|
+
if (referenceCount > 0) {
|
|
10004
|
+
const noun = existing.type === "movies" ? referenceCount === 1 ? "movie" : "movies" : referenceCount === 1 ? "show" : "shows";
|
|
10005
|
+
return context.json({ error: {
|
|
10006
|
+
message: `Couldn't remove library — ${referenceCount} ${noun} reference it. Remove them first or use 'Force remove'.`,
|
|
10007
|
+
referenceCount
|
|
10008
|
+
} }, 409);
|
|
10009
|
+
}
|
|
10010
|
+
db.delete(libraries).where(eq(libraries.id, id)).run();
|
|
10011
|
+
return context.json({ success: true });
|
|
10012
|
+
}
|
|
10013
|
+
db.transaction((tx) => {
|
|
10014
|
+
if (existing.type === "movies") {
|
|
10015
|
+
const movieIds = tx.select({ id: movies.id }).from(movies).where(sql`${movies.filePath} LIKE ${pattern} ESCAPE '\\'`).all().map((row) => row.id);
|
|
10016
|
+
if (movieIds.length > 0) {
|
|
10017
|
+
tx.delete(watchProgress).where(inArray(watchProgress.movieId, movieIds)).run();
|
|
10018
|
+
tx.delete(movies).where(inArray(movies.id, movieIds)).run();
|
|
10019
|
+
}
|
|
10020
|
+
} else {
|
|
10021
|
+
const showIds = tx.select({ id: shows.id }).from(shows).where(sql`${shows.folderPath} LIKE ${pattern} ESCAPE '\\'`).all().map((row) => row.id);
|
|
10022
|
+
if (showIds.length > 0) {
|
|
10023
|
+
const episodeIds = tx.select({ id: episodes.id }).from(episodes).where(inArray(episodes.showId, showIds)).all().map((row) => row.id);
|
|
10024
|
+
if (episodeIds.length > 0) {
|
|
10025
|
+
tx.delete(watchProgress).where(inArray(watchProgress.episodeId, episodeIds)).run();
|
|
10026
|
+
tx.delete(episodes).where(inArray(episodes.id, episodeIds)).run();
|
|
10027
|
+
}
|
|
10028
|
+
tx.delete(seasons).where(inArray(seasons.showId, showIds)).run();
|
|
10029
|
+
tx.delete(shows).where(inArray(shows.id, showIds)).run();
|
|
10030
|
+
}
|
|
10031
|
+
}
|
|
10032
|
+
tx.delete(libraries).where(eq(libraries.id, id)).run();
|
|
10033
|
+
});
|
|
10034
|
+
return context.json({ success: true });
|
|
10035
|
+
});
|
|
10036
|
+
function countLibraryReferences(library, pattern) {
|
|
10037
|
+
if (library.type === "movies") {
|
|
10038
|
+
const [row] = db.select({ count: sql`count(*)` }).from(movies).where(sql`${movies.filePath} LIKE ${pattern} ESCAPE '\\'`).all();
|
|
10039
|
+
return row?.count ?? 0;
|
|
10040
|
+
}
|
|
10041
|
+
const [row] = db.select({ count: sql`count(*)` }).from(shows).where(sql`${shows.folderPath} LIKE ${pattern} ESCAPE '\\'`).all();
|
|
10042
|
+
return row?.count ?? 0;
|
|
10043
|
+
}
|
|
10044
|
+
settingsRoutes.get("/scan-schedule", async (context) => {
|
|
10045
|
+
const stored = await getSetting(scanScheduleSettingKey);
|
|
10046
|
+
const value = stored !== null && isScanScheduleValue(stored) ? stored : "off";
|
|
10047
|
+
const info = scheduler.getInfo();
|
|
10048
|
+
return context.json({
|
|
10049
|
+
nextRunAt: info.nextRunAt?.toISOString() ?? null,
|
|
10050
|
+
schedule: value
|
|
10051
|
+
});
|
|
10052
|
+
});
|
|
10053
|
+
settingsRoutes.put("/scan-schedule", async (context) => {
|
|
10054
|
+
let body;
|
|
10055
|
+
try {
|
|
10056
|
+
body = await context.req.json();
|
|
10057
|
+
} catch {
|
|
10058
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
10059
|
+
}
|
|
10060
|
+
const schedule = typeof body.schedule === "string" ? body.schedule.trim() : "";
|
|
10061
|
+
if (!isScanScheduleValue(schedule)) return context.json({ error: { message: "Scan schedule must be one of: off, 6h, 12h, 24h." } }, 400);
|
|
10062
|
+
await setSetting(scanScheduleSettingKey, schedule);
|
|
10063
|
+
scheduler.updateSchedule(schedule);
|
|
10064
|
+
const info = scheduler.getInfo();
|
|
10065
|
+
return context.json({
|
|
10066
|
+
nextRunAt: info.nextRunAt?.toISOString() ?? null,
|
|
10067
|
+
schedule
|
|
10068
|
+
});
|
|
10069
|
+
});
|
|
10070
|
+
settingsRoutes.get("/:key", async (context) => {
|
|
10071
|
+
const key = context.req.param("key");
|
|
10072
|
+
if (reservedKeys.has(key)) return context.json({ error: { message: `Use GET ${reservedKeyRoute[key]} for this key.` } }, 400);
|
|
10073
|
+
const value = await getSetting(key);
|
|
10074
|
+
if (value === null) return context.json({ value: null });
|
|
10075
|
+
return context.json({ value });
|
|
10076
|
+
});
|
|
10077
|
+
settingsRoutes.put("/:key", async (context) => {
|
|
10078
|
+
const key = context.req.param("key");
|
|
10079
|
+
if (reservedKeys.has(key)) return context.json({ error: { message: `Use PUT ${reservedKeyRoute[key]} for this key.` } }, 400);
|
|
10080
|
+
let body;
|
|
10081
|
+
try {
|
|
10082
|
+
body = await context.req.json();
|
|
10083
|
+
} catch {
|
|
10084
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
10085
|
+
}
|
|
10086
|
+
if (typeof body.value !== "string") return context.json({ error: { message: "Setting value must be a string." } }, 400);
|
|
10087
|
+
await setSetting(key, body.value);
|
|
10088
|
+
return context.json({ value: body.value });
|
|
8573
10089
|
});
|
|
8574
10090
|
//#endregion
|
|
8575
10091
|
//#region src/api/routes/setup.ts
|
|
8576
10092
|
const setupRoutes = new Hono();
|
|
8577
10093
|
setupRoutes.get("/", async (context) => {
|
|
8578
|
-
const config = getConfig();
|
|
8579
10094
|
const libraries$1 = await db.select().from(libraries);
|
|
8580
10095
|
return context.json({
|
|
8581
|
-
config: config ? { tmdbApiKey: config.tmdbApiKey } : null,
|
|
8582
|
-
hasApiKey: config?.tmdbApiKey !== void 0 && config.tmdbApiKey !== "",
|
|
8583
10096
|
libraries: libraries$1.map((library) => ({
|
|
8584
10097
|
id: library.id,
|
|
8585
10098
|
name: library.name,
|
|
@@ -8592,9 +10105,7 @@ setupRoutes.get("/", async (context) => {
|
|
|
8592
10105
|
});
|
|
8593
10106
|
setupRoutes.post("/complete", async (context) => {
|
|
8594
10107
|
const body = await context.req.json();
|
|
8595
|
-
if (!body.tmdbApiKey) return context.json({ error: { message: "TMDB API key is required" } }, 400);
|
|
8596
10108
|
if (!body.libraries || body.libraries.length === 0) return context.json({ error: { message: "At least one library is required" } }, 400);
|
|
8597
|
-
await saveConfig({ tmdbApiKey: body.tmdbApiKey });
|
|
8598
10109
|
await db.delete(libraries);
|
|
8599
10110
|
const now = /* @__PURE__ */ new Date();
|
|
8600
10111
|
for (const library of body.libraries) await db.insert(libraries).values({
|
|
@@ -8746,17 +10257,6 @@ favoriteRoutes.delete("/", async (context) => {
|
|
|
8746
10257
|
return context.json({ error: { message: "movieId or showId required" } }, 400);
|
|
8747
10258
|
});
|
|
8748
10259
|
//#endregion
|
|
8749
|
-
//#region src/lib/watched.ts
|
|
8750
|
-
/**
|
|
8751
|
-
* Shared "watched" threshold used across the server and client.
|
|
8752
|
-
*
|
|
8753
|
-
* An episode (or movie) is considered complete once the profile has
|
|
8754
|
-
* progressed at least this fraction of the total duration. Kept in a single
|
|
8755
|
-
* module so server endpoints ("Up Next", next-episode) and client overlays
|
|
8756
|
-
* ("Up Next" countdown) can't drift.
|
|
8757
|
-
*/
|
|
8758
|
-
const watchedThreshold = .9;
|
|
8759
|
-
//#endregion
|
|
8760
10260
|
//#region src/api/routes/shows.ts
|
|
8761
10261
|
const showRoutes = new Hono();
|
|
8762
10262
|
showRoutes.get("/", async (context) => {
|
|
@@ -8778,38 +10278,31 @@ showRoutes.get("/episodes/:id", async (context) => {
|
|
|
8778
10278
|
const [episode] = await db.select().from(episodes).where(eq(episodes.id, id)).limit(1);
|
|
8779
10279
|
if (!episode) return context.json({ error: { message: "Episode not found" } }, 404);
|
|
8780
10280
|
const [show] = await db.select().from(shows).where(eq(shows.id, episode.showId)).limit(1);
|
|
10281
|
+
const subtitles$1 = (await db.select().from(subtitles).where(eq(subtitles.episodeId, id))).map((row) => ({
|
|
10282
|
+
id: row.id,
|
|
10283
|
+
displayLanguage: languageDisplayName(row.language),
|
|
10284
|
+
format: row.format,
|
|
10285
|
+
isSupported: row.format !== "ass",
|
|
10286
|
+
language: row.language
|
|
10287
|
+
}));
|
|
8781
10288
|
return context.json({
|
|
8782
10289
|
episode,
|
|
8783
|
-
show
|
|
10290
|
+
show,
|
|
10291
|
+
subtitles: subtitles$1
|
|
8784
10292
|
});
|
|
8785
10293
|
});
|
|
8786
10294
|
showRoutes.get("/:showId/next-episode", async (context) => {
|
|
8787
|
-
const profileId = context.get("profileId");
|
|
8788
10295
|
const showId = parseInt(context.req.param("showId"), 10);
|
|
8789
10296
|
const afterEpisodeIdParam = context.req.query("afterEpisodeId");
|
|
8790
10297
|
const afterEpisodeId = afterEpisodeIdParam ? parseInt(afterEpisodeIdParam, 10) : NaN;
|
|
8791
10298
|
if (isNaN(showId) || isNaN(afterEpisodeId)) return context.json({ error: { message: "Provide a numeric showId and a numeric afterEpisodeId query parameter." } }, 400);
|
|
8792
10299
|
const [currentEpisode] = await db.select().from(episodes).where(and(eq(episodes.id, afterEpisodeId), eq(episodes.showId, showId))).limit(1);
|
|
8793
10300
|
if (!currentEpisode) return context.json({ error: { message: "That episode does not belong to this show. Double-check the showId and afterEpisodeId." } }, 404);
|
|
8794
|
-
const
|
|
10301
|
+
const nextEpisode = (await db.select().from(episodes).where(eq(episodes.showId, showId)).orderBy(episodes.seasonNumber, episodes.episodeNumber)).find((candidate) => {
|
|
8795
10302
|
if (candidate.seasonNumber > currentEpisode.seasonNumber) return true;
|
|
8796
10303
|
if (candidate.seasonNumber < currentEpisode.seasonNumber) return false;
|
|
8797
10304
|
return candidate.episodeNumber > currentEpisode.episodeNumber;
|
|
8798
10305
|
});
|
|
8799
|
-
if (laterEpisodes.length === 0) return context.json({ error: { message: "No more episodes after this one." } }, 404);
|
|
8800
|
-
const laterIds = laterEpisodes.map((candidate) => candidate.id);
|
|
8801
|
-
const progressRows = await db.select().from(watchProgress).where(eq(watchProgress.profileId, profileId));
|
|
8802
|
-
const progressByEpisodeId = /* @__PURE__ */ new Map();
|
|
8803
|
-
for (const row of progressRows) if (row.episodeId !== null && laterIds.includes(row.episodeId)) progressByEpisodeId.set(row.episodeId, {
|
|
8804
|
-
currentTime: row.currentTime,
|
|
8805
|
-
duration: row.duration
|
|
8806
|
-
});
|
|
8807
|
-
const nextEpisode = laterEpisodes.find((candidate) => {
|
|
8808
|
-
const progress = progressByEpisodeId.get(candidate.id);
|
|
8809
|
-
if (!progress) return true;
|
|
8810
|
-
if (!progress.duration || progress.duration <= 0) return true;
|
|
8811
|
-
return progress.currentTime / progress.duration < watchedThreshold;
|
|
8812
|
-
});
|
|
8813
10306
|
if (!nextEpisode) return context.json({ error: { message: "No more episodes after this one." } }, 404);
|
|
8814
10307
|
const [show] = await db.select().from(shows).where(eq(shows.id, showId)).limit(1);
|
|
8815
10308
|
return context.json({
|
|
@@ -8893,6 +10386,60 @@ streamRoutes.get("/:id", async (context) => {
|
|
|
8893
10386
|
} });
|
|
8894
10387
|
});
|
|
8895
10388
|
//#endregion
|
|
10389
|
+
//#region src/api/routes/subtitles.ts
|
|
10390
|
+
const subtitleRoutes = new Hono();
|
|
10391
|
+
const conversionFailedMessage = "Couldn't convert subtitle file. Check it's a valid SRT.";
|
|
10392
|
+
const outsideLibraryMessage = "Subtitle is outside the configured library paths. Rescan your libraries.";
|
|
10393
|
+
function subtitleIsInsideALibrary(subtitleFilePath, libraryPaths) {
|
|
10394
|
+
const resolvedSubtitle = path.resolve(subtitleFilePath);
|
|
10395
|
+
for (const libraryPath of libraryPaths) {
|
|
10396
|
+
const resolvedLibrary = path.resolve(libraryPath);
|
|
10397
|
+
const libraryWithSeparator = resolvedLibrary.endsWith(path.sep) ? resolvedLibrary : `${resolvedLibrary}${path.sep}`;
|
|
10398
|
+
if (resolvedSubtitle.startsWith(libraryWithSeparator)) return true;
|
|
10399
|
+
}
|
|
10400
|
+
return false;
|
|
10401
|
+
}
|
|
10402
|
+
subtitleRoutes.get("/:id", async (context) => {
|
|
10403
|
+
const id = parseInt(context.req.param("id"), 10);
|
|
10404
|
+
if (isNaN(id)) return context.json({ error: { message: "Invalid subtitle ID" } }, 400);
|
|
10405
|
+
const [subtitle] = await db.select().from(subtitles).where(eq(subtitles.id, id)).limit(1);
|
|
10406
|
+
if (!subtitle) return context.json({ error: { message: "Subtitle not found" } }, 404);
|
|
10407
|
+
const libraryPaths = (await db.select({ path: libraries.path }).from(libraries)).map((library) => library.path);
|
|
10408
|
+
if (!subtitleIsInsideALibrary(subtitle.filePath, libraryPaths)) {
|
|
10409
|
+
log.warn(`Refusing to serve subtitle ${subtitle.id} — ${subtitle.filePath} is not inside any configured library. Rescan the library to clean up stale rows.`);
|
|
10410
|
+
return context.json({ error: { message: outsideLibraryMessage } }, 404);
|
|
10411
|
+
}
|
|
10412
|
+
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);
|
|
10413
|
+
let raw;
|
|
10414
|
+
try {
|
|
10415
|
+
raw = await readFile(subtitle.filePath, "utf-8");
|
|
10416
|
+
} catch (error) {
|
|
10417
|
+
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.`);
|
|
10418
|
+
return context.text(conversionFailedMessage, 500);
|
|
10419
|
+
}
|
|
10420
|
+
if (subtitle.format === "vtt") return new Response(raw, {
|
|
10421
|
+
status: 200,
|
|
10422
|
+
headers: {
|
|
10423
|
+
"Cache-Control": "public, max-age=3600",
|
|
10424
|
+
"Content-Type": "text/vtt; charset=utf-8"
|
|
10425
|
+
}
|
|
10426
|
+
});
|
|
10427
|
+
let converted;
|
|
10428
|
+
try {
|
|
10429
|
+
converted = convertSrtToVtt(raw);
|
|
10430
|
+
} catch (error) {
|
|
10431
|
+
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.`);
|
|
10432
|
+
return context.text(conversionFailedMessage, 500);
|
|
10433
|
+
}
|
|
10434
|
+
return new Response(converted, {
|
|
10435
|
+
status: 200,
|
|
10436
|
+
headers: {
|
|
10437
|
+
"Cache-Control": "public, max-age=3600",
|
|
10438
|
+
"Content-Type": "text/vtt; charset=utf-8"
|
|
10439
|
+
}
|
|
10440
|
+
});
|
|
10441
|
+
});
|
|
10442
|
+
//#endregion
|
|
8896
10443
|
//#region src/api/routes/transcode.ts
|
|
8897
10444
|
const logger = {
|
|
8898
10445
|
error: (...args) => console.error("[transcode]", ...args),
|
|
@@ -9089,6 +10636,17 @@ try {
|
|
|
9089
10636
|
logger.warn("Failed to clear transcode root on boot:", error);
|
|
9090
10637
|
}
|
|
9091
10638
|
//#endregion
|
|
10639
|
+
//#region src/lib/watched.ts
|
|
10640
|
+
/**
|
|
10641
|
+
* Shared "watched" threshold used across the server and client.
|
|
10642
|
+
*
|
|
10643
|
+
* An episode (or movie) is considered complete once the profile has
|
|
10644
|
+
* progressed at least this fraction of the total duration. Kept in a single
|
|
10645
|
+
* module so server endpoints ("Up Next", next-episode) and client overlays
|
|
10646
|
+
* ("Up Next" countdown) can't drift.
|
|
10647
|
+
*/
|
|
10648
|
+
const watchedThreshold = .9;
|
|
10649
|
+
//#endregion
|
|
9092
10650
|
//#region src/api/routes/up-next.ts
|
|
9093
10651
|
const upNextRoutes = new Hono();
|
|
9094
10652
|
upNextRoutes.get("/", async (context) => {
|
|
@@ -9154,11 +10712,15 @@ app.route("/api/library", libraryRoutes);
|
|
|
9154
10712
|
app.route("/api/progress/episode", episodeProgressRoutes);
|
|
9155
10713
|
app.route("/api/progress", progressRoutes);
|
|
9156
10714
|
app.route("/api/scan", scanRoutes);
|
|
10715
|
+
app.route("/api/search", searchRoutes);
|
|
9157
10716
|
app.route("/api/profiles", profileRoutes);
|
|
9158
10717
|
app.route("/api/favorites", favoriteRoutes);
|
|
10718
|
+
app.route("/api/filesystem", filesystemRoutes);
|
|
10719
|
+
app.route("/api/settings", settingsRoutes);
|
|
9159
10720
|
app.route("/api/setup", setupRoutes);
|
|
9160
10721
|
app.route("/api/stream/episode", episodeStreamRoutes);
|
|
9161
10722
|
app.route("/api/stream", streamRoutes);
|
|
10723
|
+
app.route("/api/subtitles", subtitleRoutes);
|
|
9162
10724
|
app.route("/api/transcode", transcodeRoutes);
|
|
9163
10725
|
app.get("/api", (c) => c.json({ message: "Jukebox API" }));
|
|
9164
10726
|
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -9237,6 +10799,8 @@ if (isDevelopment) {
|
|
|
9237
10799
|
await viteServer.listen();
|
|
9238
10800
|
setupViteProxy(vitePort);
|
|
9239
10801
|
} else setupStaticServing();
|
|
10802
|
+
await scanManager.recoverInterruptedJobs();
|
|
10803
|
+
await scheduler.start();
|
|
9240
10804
|
const server = serve({
|
|
9241
10805
|
fetch: app.fetch,
|
|
9242
10806
|
port
|
|
@@ -9262,6 +10826,7 @@ function printWelcome(boundPort) {
|
|
|
9262
10826
|
function shutdown(signal) {
|
|
9263
10827
|
console.log(`\nReceived ${signal}, shutting down...`);
|
|
9264
10828
|
setTimeout(() => process.exit(1), 2e3).unref();
|
|
10829
|
+
scheduler.stop();
|
|
9265
10830
|
server.close(() => {
|
|
9266
10831
|
if (viteServer) viteServer.close().finally(() => process.exit(0));
|
|
9267
10832
|
else process.exit(0);
|