jukebox-media-server 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{Watch-kJkUCvFd.js → Watch-DNFM488K.js} +32 -32
- package/dist/client/assets/index-BYBYJ0kS.css +1 -0
- package/dist/client/assets/index-COFfIwFC.js +58 -0
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/index.js +1772 -206
- package/drizzle/0005_settings_table.sql +5 -0
- package/drizzle/0006_watch_progress_cascade.sql +20 -0
- package/drizzle/0007_scan_jobs.sql +10 -0
- package/drizzle/0008_search_fts.sql +154 -0
- package/drizzle/0009_subtitles.sql +10 -0
- package/drizzle/meta/0005_snapshot.json +911 -0
- package/drizzle/meta/0006_snapshot.json +911 -0
- package/drizzle/meta/0007_snapshot.json +980 -0
- package/drizzle/meta/0008_snapshot.json +980 -0
- package/drizzle/meta/0009_snapshot.json +1059 -0
- package/drizzle/meta/_journal.json +36 -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";
|
|
12
|
+
import { access, readFile, readdir, stat, writeFile } from "fs/promises";
|
|
13
13
|
import os, { homedir } from "os";
|
|
14
14
|
import { promisify } from "util";
|
|
15
|
+
import { EventEmitter } from "node:events";
|
|
16
|
+
import process$1 from "node:process";
|
|
17
|
+
import os$1 from "node:os";
|
|
18
|
+
import tty from "node:tty";
|
|
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
|
|
@@ -6812,9 +6816,12 @@ var schema_exports = /* @__PURE__ */ __exportAll({
|
|
|
6812
6816
|
libraries: () => libraries,
|
|
6813
6817
|
movies: () => movies,
|
|
6814
6818
|
profiles: () => profiles,
|
|
6819
|
+
scanJobs: () => scanJobs,
|
|
6815
6820
|
seasons: () => seasons,
|
|
6816
6821
|
sessions: () => sessions$1,
|
|
6822
|
+
settings: () => settings,
|
|
6817
6823
|
shows: () => shows,
|
|
6824
|
+
subtitles: () => subtitles,
|
|
6818
6825
|
watchProgress: () => watchProgress
|
|
6819
6826
|
});
|
|
6820
6827
|
const profiles = sqliteTable("profiles", {
|
|
@@ -6893,8 +6900,8 @@ const episodes = sqliteTable("episodes", {
|
|
|
6893
6900
|
const watchProgress = sqliteTable("watch_progress", {
|
|
6894
6901
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6895
6902
|
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),
|
|
6903
|
+
movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
|
|
6904
|
+
episodeId: integer("episode_id").references(() => episodes.id, { onDelete: "cascade" }),
|
|
6898
6905
|
currentTime: integer("current_time").notNull(),
|
|
6899
6906
|
duration: integer("duration"),
|
|
6900
6907
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
|
|
@@ -6918,10 +6925,34 @@ const sessions$1 = sqliteTable("sessions", {
|
|
|
6918
6925
|
lastSeenAt: integer("last_seen_at").notNull(),
|
|
6919
6926
|
userAgent: text("user_agent")
|
|
6920
6927
|
});
|
|
6928
|
+
const settings = sqliteTable("settings", {
|
|
6929
|
+
key: text("key").primaryKey(),
|
|
6930
|
+
value: text("value").notNull(),
|
|
6931
|
+
updatedAt: integer("updated_at").notNull()
|
|
6932
|
+
});
|
|
6933
|
+
const scanJobs = sqliteTable("scan_jobs", {
|
|
6934
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6935
|
+
startedAt: integer("started_at", { mode: "timestamp" }).notNull(),
|
|
6936
|
+
endedAt: integer("ended_at", { mode: "timestamp" }),
|
|
6937
|
+
status: text("status").notNull(),
|
|
6938
|
+
added: integer("added").notNull().default(0),
|
|
6939
|
+
updated: integer("updated").notNull().default(0),
|
|
6940
|
+
total: integer("total").notNull().default(0),
|
|
6941
|
+
errorMessage: text("error_message")
|
|
6942
|
+
});
|
|
6943
|
+
const subtitles = sqliteTable("subtitles", {
|
|
6944
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
6945
|
+
movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
|
|
6946
|
+
episodeId: integer("episode_id").references(() => episodes.id, { onDelete: "cascade" }),
|
|
6947
|
+
filePath: text("file_path").notNull(),
|
|
6948
|
+
language: text("language").notNull(),
|
|
6949
|
+
format: text("format").notNull()
|
|
6950
|
+
});
|
|
6921
6951
|
//#endregion
|
|
6922
6952
|
//#region src/database/index.ts
|
|
6923
6953
|
ensureConfigDirectory();
|
|
6924
6954
|
const sqlite = new Database(databasePath);
|
|
6955
|
+
sqlite.pragma("foreign_keys = ON");
|
|
6925
6956
|
const db = drizzle(sqlite, { schema: schema_exports });
|
|
6926
6957
|
const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
|
|
6927
6958
|
migrate(db, { migrationsFolder: path.resolve(__dirname$2, "../../drizzle") });
|
|
@@ -7605,6 +7636,309 @@ helloRoutes.put("/", (c) => c.json({
|
|
|
7605
7636
|
}));
|
|
7606
7637
|
helloRoutes.get("/:name", (c) => c.json({ message: `Hello, ${c.req.param("name")}!` }));
|
|
7607
7638
|
//#endregion
|
|
7639
|
+
//#region src/services/filename-parser.ts
|
|
7640
|
+
const subtitleExtensions$1 = {
|
|
7641
|
+
".ass": "ass",
|
|
7642
|
+
".srt": "srt",
|
|
7643
|
+
".vtt": "vtt"
|
|
7644
|
+
};
|
|
7645
|
+
const threeLetterToTwoLetter = {
|
|
7646
|
+
ara: "ar",
|
|
7647
|
+
ces: "cs",
|
|
7648
|
+
cze: "cs",
|
|
7649
|
+
dan: "da",
|
|
7650
|
+
deu: "de",
|
|
7651
|
+
dut: "nl",
|
|
7652
|
+
ell: "el",
|
|
7653
|
+
eng: "en",
|
|
7654
|
+
fin: "fi",
|
|
7655
|
+
fra: "fr",
|
|
7656
|
+
fre: "fr",
|
|
7657
|
+
ger: "de",
|
|
7658
|
+
gre: "el",
|
|
7659
|
+
heb: "he",
|
|
7660
|
+
hin: "hi",
|
|
7661
|
+
hun: "hu",
|
|
7662
|
+
ind: "id",
|
|
7663
|
+
ita: "it",
|
|
7664
|
+
jpn: "ja",
|
|
7665
|
+
kor: "ko",
|
|
7666
|
+
nld: "nl",
|
|
7667
|
+
nor: "no",
|
|
7668
|
+
pol: "pl",
|
|
7669
|
+
por: "pt",
|
|
7670
|
+
ron: "ro",
|
|
7671
|
+
rum: "ro",
|
|
7672
|
+
rus: "ru",
|
|
7673
|
+
spa: "es",
|
|
7674
|
+
swe: "sv",
|
|
7675
|
+
tha: "th",
|
|
7676
|
+
tur: "tr",
|
|
7677
|
+
ukr: "uk",
|
|
7678
|
+
vie: "vi",
|
|
7679
|
+
zho: "zh",
|
|
7680
|
+
chi: "zh"
|
|
7681
|
+
};
|
|
7682
|
+
const fullNameToCode = {
|
|
7683
|
+
arabic: "ar",
|
|
7684
|
+
chinese: "zh",
|
|
7685
|
+
czech: "cs",
|
|
7686
|
+
danish: "da",
|
|
7687
|
+
dutch: "nl",
|
|
7688
|
+
english: "en",
|
|
7689
|
+
finnish: "fi",
|
|
7690
|
+
french: "fr",
|
|
7691
|
+
german: "de",
|
|
7692
|
+
greek: "el",
|
|
7693
|
+
hebrew: "he",
|
|
7694
|
+
hindi: "hi",
|
|
7695
|
+
hungarian: "hu",
|
|
7696
|
+
indonesian: "id",
|
|
7697
|
+
italian: "it",
|
|
7698
|
+
japanese: "ja",
|
|
7699
|
+
korean: "ko",
|
|
7700
|
+
norwegian: "no",
|
|
7701
|
+
polish: "pl",
|
|
7702
|
+
portuguese: "pt",
|
|
7703
|
+
romanian: "ro",
|
|
7704
|
+
russian: "ru",
|
|
7705
|
+
spanish: "es",
|
|
7706
|
+
swedish: "sv",
|
|
7707
|
+
thai: "th",
|
|
7708
|
+
turkish: "tr",
|
|
7709
|
+
ukrainian: "uk",
|
|
7710
|
+
vietnamese: "vi"
|
|
7711
|
+
};
|
|
7712
|
+
const subtitleModifiers = new Set([
|
|
7713
|
+
"cc",
|
|
7714
|
+
"forced",
|
|
7715
|
+
"hoh",
|
|
7716
|
+
"sdh"
|
|
7717
|
+
]);
|
|
7718
|
+
/**
|
|
7719
|
+
* Parse a movie filename to extract title and year.
|
|
7720
|
+
* The year marks the boundary between the title and technical info.
|
|
7721
|
+
*
|
|
7722
|
+
* Examples:
|
|
7723
|
+
* - Jurassic.Park.1993.720p.BrRip.264.YIFY.mp4 -> { title: "Jurassic Park", year: 1993 }
|
|
7724
|
+
* - The.Social.Network.(2010).1080p.BrRip.x264.mp4 -> { title: "The Social Network", year: 2010 }
|
|
7725
|
+
*/
|
|
7726
|
+
function parseFilename(fileName) {
|
|
7727
|
+
const name = fileName.replace(/\.[^.]+$/, "");
|
|
7728
|
+
const bracketYearMatch = name.match(/[([]((?:19|20)\d{2})[)\]]/);
|
|
7729
|
+
if (bracketYearMatch?.[1]) {
|
|
7730
|
+
const year = parseInt(bracketYearMatch[1], 10);
|
|
7731
|
+
const yearIndex = bracketYearMatch.index ?? 0;
|
|
7732
|
+
let title = name.substring(0, yearIndex);
|
|
7733
|
+
title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
|
|
7734
|
+
title = title.replace(/[-–—]+$/, "").trim();
|
|
7735
|
+
return {
|
|
7736
|
+
title,
|
|
7737
|
+
year
|
|
7738
|
+
};
|
|
7739
|
+
}
|
|
7740
|
+
const yearPattern = /[.\s]((?:19|20)\d{2})[.\s]/g;
|
|
7741
|
+
let match;
|
|
7742
|
+
let bestMatch = null;
|
|
7743
|
+
while ((match = yearPattern.exec(name)) !== null) {
|
|
7744
|
+
const yearStr = match[1];
|
|
7745
|
+
if (!yearStr) continue;
|
|
7746
|
+
const year = parseInt(yearStr, 10);
|
|
7747
|
+
const afterYear = name.substring(match.index + match[0].length);
|
|
7748
|
+
const looksLikeTechInfo = /^(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|HDTV|DVDRip|x264|x265|H\.?264)/i.test(afterYear);
|
|
7749
|
+
if (looksLikeTechInfo || !bestMatch) {
|
|
7750
|
+
bestMatch = {
|
|
7751
|
+
year,
|
|
7752
|
+
index: match.index
|
|
7753
|
+
};
|
|
7754
|
+
if (looksLikeTechInfo) break;
|
|
7755
|
+
}
|
|
7756
|
+
}
|
|
7757
|
+
if (bestMatch) {
|
|
7758
|
+
let title = name.substring(0, bestMatch.index);
|
|
7759
|
+
title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
|
|
7760
|
+
title = title.replace(/[-–—]+$/, "").trim();
|
|
7761
|
+
return {
|
|
7762
|
+
title,
|
|
7763
|
+
year: bestMatch.year
|
|
7764
|
+
};
|
|
7765
|
+
}
|
|
7766
|
+
return {
|
|
7767
|
+
title: name.replace(/[._]/g, " ").replace(/\s+/g, " ").trim(),
|
|
7768
|
+
year: null
|
|
7769
|
+
};
|
|
7770
|
+
}
|
|
7771
|
+
/**
|
|
7772
|
+
* Extract year from a filename if present
|
|
7773
|
+
*/
|
|
7774
|
+
function extractYear(fileName) {
|
|
7775
|
+
return parseFilename(fileName).year;
|
|
7776
|
+
}
|
|
7777
|
+
/**
|
|
7778
|
+
* Clean a movie filename to extract a readable title
|
|
7779
|
+
*/
|
|
7780
|
+
function cleanTitle(fileName) {
|
|
7781
|
+
return parseFilename(fileName).title;
|
|
7782
|
+
}
|
|
7783
|
+
function normalizeLanguageToken(rawToken) {
|
|
7784
|
+
const token = rawToken.toLowerCase();
|
|
7785
|
+
if (subtitleModifiers.has(token)) return null;
|
|
7786
|
+
if (fullNameToCode[token]) return fullNameToCode[token];
|
|
7787
|
+
if (threeLetterToTwoLetter[token]) return threeLetterToTwoLetter[token];
|
|
7788
|
+
if (/^[a-z]{2}$/.test(token)) return token;
|
|
7789
|
+
if (/^[a-z]{3}$/.test(token)) return token;
|
|
7790
|
+
return null;
|
|
7791
|
+
}
|
|
7792
|
+
/**
|
|
7793
|
+
* Parse a sidecar subtitle filename. Returns the base name (the video filename
|
|
7794
|
+
* without the language suffix or extension), the detected language as a
|
|
7795
|
+
* lowercase code (ISO-639-1 when known, otherwise the raw token, or 'und' when
|
|
7796
|
+
* the filename has no language hint), and the subtitle format.
|
|
7797
|
+
*
|
|
7798
|
+
* Returns null when the file isn't a recognized subtitle format.
|
|
7799
|
+
*
|
|
7800
|
+
* Supported patterns:
|
|
7801
|
+
* - Movie.srt → language 'und'
|
|
7802
|
+
* - Movie.en.srt / Movie.eng.srt → 'en'
|
|
7803
|
+
* - Movie.English.srt → 'en'
|
|
7804
|
+
* - Movie.en.forced.srt → 'en' (forced/sdh/cc/hoh modifiers ignored)
|
|
7805
|
+
* - Show.S01E02.en.srt → baseName 'Show.S01E02', language 'en'
|
|
7806
|
+
*/
|
|
7807
|
+
function parseSubtitleFilename(fileName) {
|
|
7808
|
+
const lastDot = fileName.lastIndexOf(".");
|
|
7809
|
+
if (lastDot < 0) return null;
|
|
7810
|
+
const format = subtitleExtensions$1[fileName.substring(lastDot).toLowerCase()];
|
|
7811
|
+
if (!format) return null;
|
|
7812
|
+
const tokens = fileName.substring(0, lastDot).split(".");
|
|
7813
|
+
let language = "und";
|
|
7814
|
+
let baseEndIndex = tokens.length;
|
|
7815
|
+
for (let cursor = tokens.length - 1; cursor >= 1; cursor--) {
|
|
7816
|
+
const token = tokens[cursor];
|
|
7817
|
+
if (!token) continue;
|
|
7818
|
+
const lowered = token.toLowerCase();
|
|
7819
|
+
if (subtitleModifiers.has(lowered)) {
|
|
7820
|
+
baseEndIndex = cursor;
|
|
7821
|
+
continue;
|
|
7822
|
+
}
|
|
7823
|
+
const normalized = normalizeLanguageToken(token);
|
|
7824
|
+
if (normalized) {
|
|
7825
|
+
language = normalized;
|
|
7826
|
+
baseEndIndex = cursor;
|
|
7827
|
+
}
|
|
7828
|
+
break;
|
|
7829
|
+
}
|
|
7830
|
+
return {
|
|
7831
|
+
baseName: tokens.slice(0, baseEndIndex).join("."),
|
|
7832
|
+
format,
|
|
7833
|
+
language
|
|
7834
|
+
};
|
|
7835
|
+
}
|
|
7836
|
+
//#endregion
|
|
7837
|
+
//#region src/services/media-extensions.ts
|
|
7838
|
+
const videoExtensions$1 = new Set([
|
|
7839
|
+
".mp4",
|
|
7840
|
+
".mkv",
|
|
7841
|
+
".avi",
|
|
7842
|
+
".mov",
|
|
7843
|
+
".wmv",
|
|
7844
|
+
".m4v",
|
|
7845
|
+
".webm",
|
|
7846
|
+
".flv",
|
|
7847
|
+
".mpeg",
|
|
7848
|
+
".mpg"
|
|
7849
|
+
]);
|
|
7850
|
+
const subtitleExtensions = new Set([
|
|
7851
|
+
".ass",
|
|
7852
|
+
".srt",
|
|
7853
|
+
".vtt"
|
|
7854
|
+
]);
|
|
7855
|
+
//#endregion
|
|
7856
|
+
//#region src/services/subtitles.ts
|
|
7857
|
+
const languageNames = {
|
|
7858
|
+
ar: "Arabic",
|
|
7859
|
+
cs: "Czech",
|
|
7860
|
+
da: "Danish",
|
|
7861
|
+
de: "German",
|
|
7862
|
+
el: "Greek",
|
|
7863
|
+
en: "English",
|
|
7864
|
+
es: "Spanish",
|
|
7865
|
+
fi: "Finnish",
|
|
7866
|
+
fr: "French",
|
|
7867
|
+
he: "Hebrew",
|
|
7868
|
+
hi: "Hindi",
|
|
7869
|
+
hu: "Hungarian",
|
|
7870
|
+
id: "Indonesian",
|
|
7871
|
+
it: "Italian",
|
|
7872
|
+
ja: "Japanese",
|
|
7873
|
+
ko: "Korean",
|
|
7874
|
+
nl: "Dutch",
|
|
7875
|
+
no: "Norwegian",
|
|
7876
|
+
pl: "Polish",
|
|
7877
|
+
pt: "Portuguese",
|
|
7878
|
+
ro: "Romanian",
|
|
7879
|
+
ru: "Russian",
|
|
7880
|
+
sv: "Swedish",
|
|
7881
|
+
th: "Thai",
|
|
7882
|
+
tr: "Turkish",
|
|
7883
|
+
uk: "Ukrainian",
|
|
7884
|
+
vi: "Vietnamese",
|
|
7885
|
+
zh: "Chinese"
|
|
7886
|
+
};
|
|
7887
|
+
/**
|
|
7888
|
+
* Map a language code to a human-readable label. Returns "Unknown" for the
|
|
7889
|
+
* undetermined sentinel and the raw code for languages not in our table.
|
|
7890
|
+
*/
|
|
7891
|
+
function languageDisplayName(code) {
|
|
7892
|
+
if (code === "und") return "Unknown";
|
|
7893
|
+
return languageNames[code] ?? code;
|
|
7894
|
+
}
|
|
7895
|
+
function stripVideoExtension(fileName) {
|
|
7896
|
+
const extension = extname(fileName);
|
|
7897
|
+
if (!extension) return fileName;
|
|
7898
|
+
return fileName.substring(0, fileName.length - extension.length);
|
|
7899
|
+
}
|
|
7900
|
+
/**
|
|
7901
|
+
* Match sidecar subtitles to a video file. Siblings must already be filtered
|
|
7902
|
+
* to subtitle entries (see `readSubtitleSiblings`). A subtitle matches when
|
|
7903
|
+
* its base name (after stripping language suffix and modifiers) equals the
|
|
7904
|
+
* video filename without extension.
|
|
7905
|
+
*
|
|
7906
|
+
* Results are returned in stable order (by file path) so callers can compare
|
|
7907
|
+
* against existing rows without spurious diffs.
|
|
7908
|
+
*/
|
|
7909
|
+
function discoverSubtitlesForVideo(videoFilePath, siblingSubtitleFiles) {
|
|
7910
|
+
const directory = dirname(videoFilePath);
|
|
7911
|
+
const videoStem = stripVideoExtension(basename(videoFilePath));
|
|
7912
|
+
const matches = [];
|
|
7913
|
+
for (const sibling of siblingSubtitleFiles) {
|
|
7914
|
+
const parsed = parseSubtitleFilename(sibling);
|
|
7915
|
+
if (!parsed) continue;
|
|
7916
|
+
if (parsed.baseName !== videoStem) continue;
|
|
7917
|
+
matches.push({
|
|
7918
|
+
filePath: join(directory, sibling),
|
|
7919
|
+
format: parsed.format,
|
|
7920
|
+
language: parsed.language
|
|
7921
|
+
});
|
|
7922
|
+
}
|
|
7923
|
+
matches.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
7924
|
+
return matches;
|
|
7925
|
+
}
|
|
7926
|
+
/**
|
|
7927
|
+
* Convert SubRip (.srt) text into WebVTT (.vtt) text. Browsers don't natively
|
|
7928
|
+
* support .srt, so the streaming endpoint converts on the fly.
|
|
7929
|
+
*
|
|
7930
|
+
* Handles UTF-8 BOM, CRLF line endings, and the comma-vs-period decimal in
|
|
7931
|
+
* timestamps. Cue identifiers are preserved (they're optional in WebVTT but
|
|
7932
|
+
* harmless when present).
|
|
7933
|
+
*/
|
|
7934
|
+
function convertSrtToVtt(srt) {
|
|
7935
|
+
let body = srt;
|
|
7936
|
+
if (body.charCodeAt(0) === 65279) body = body.substring(1);
|
|
7937
|
+
body = body.replace(/\r\n?/g, "\n");
|
|
7938
|
+
body = body.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2 --> $3.$4");
|
|
7939
|
+
return `WEBVTT\n\n${body.trimStart()}`;
|
|
7940
|
+
}
|
|
7941
|
+
//#endregion
|
|
7608
7942
|
//#region src/api/routes/library.ts
|
|
7609
7943
|
const libraryRoutes = new Hono();
|
|
7610
7944
|
libraryRoutes.get("/movies", async (c) => {
|
|
@@ -7614,9 +7948,19 @@ libraryRoutes.get("/movies", async (c) => {
|
|
|
7614
7948
|
libraryRoutes.get("/movies/:id", async (c) => {
|
|
7615
7949
|
const id = parseInt(c.req.param("id"), 10);
|
|
7616
7950
|
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
|
-
|
|
7951
|
+
const [movie] = await db.select().from(movies).where(eq(movies.id, id)).limit(1);
|
|
7952
|
+
if (!movie) return c.json({ error: { message: "Movie not found" } }, 404);
|
|
7953
|
+
const subtitles$2 = (await db.select().from(subtitles).where(eq(subtitles.movieId, id))).map((row) => ({
|
|
7954
|
+
id: row.id,
|
|
7955
|
+
displayLanguage: languageDisplayName(row.language),
|
|
7956
|
+
format: row.format,
|
|
7957
|
+
isSupported: row.format !== "ass",
|
|
7958
|
+
language: row.language
|
|
7959
|
+
}));
|
|
7960
|
+
return c.json({
|
|
7961
|
+
...movie,
|
|
7962
|
+
subtitles: subtitles$2
|
|
7963
|
+
});
|
|
7620
7964
|
});
|
|
7621
7965
|
//#endregion
|
|
7622
7966
|
//#region src/api/routes/progress.ts
|
|
@@ -7816,15 +8160,551 @@ var streamSSE = (c, cb, onError) => {
|
|
|
7816
8160
|
return c.newResponse(stream.responseReadable);
|
|
7817
8161
|
};
|
|
7818
8162
|
//#endregion
|
|
8163
|
+
//#region node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
8164
|
+
const ANSI_BACKGROUND_OFFSET = 10;
|
|
8165
|
+
const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
|
|
8166
|
+
const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
|
|
8167
|
+
const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
|
|
8168
|
+
const styles$1 = {
|
|
8169
|
+
modifier: {
|
|
8170
|
+
reset: [0, 0],
|
|
8171
|
+
bold: [1, 22],
|
|
8172
|
+
dim: [2, 22],
|
|
8173
|
+
italic: [3, 23],
|
|
8174
|
+
underline: [4, 24],
|
|
8175
|
+
overline: [53, 55],
|
|
8176
|
+
inverse: [7, 27],
|
|
8177
|
+
hidden: [8, 28],
|
|
8178
|
+
strikethrough: [9, 29]
|
|
8179
|
+
},
|
|
8180
|
+
color: {
|
|
8181
|
+
black: [30, 39],
|
|
8182
|
+
red: [31, 39],
|
|
8183
|
+
green: [32, 39],
|
|
8184
|
+
yellow: [33, 39],
|
|
8185
|
+
blue: [34, 39],
|
|
8186
|
+
magenta: [35, 39],
|
|
8187
|
+
cyan: [36, 39],
|
|
8188
|
+
white: [37, 39],
|
|
8189
|
+
blackBright: [90, 39],
|
|
8190
|
+
gray: [90, 39],
|
|
8191
|
+
grey: [90, 39],
|
|
8192
|
+
redBright: [91, 39],
|
|
8193
|
+
greenBright: [92, 39],
|
|
8194
|
+
yellowBright: [93, 39],
|
|
8195
|
+
blueBright: [94, 39],
|
|
8196
|
+
magentaBright: [95, 39],
|
|
8197
|
+
cyanBright: [96, 39],
|
|
8198
|
+
whiteBright: [97, 39]
|
|
8199
|
+
},
|
|
8200
|
+
bgColor: {
|
|
8201
|
+
bgBlack: [40, 49],
|
|
8202
|
+
bgRed: [41, 49],
|
|
8203
|
+
bgGreen: [42, 49],
|
|
8204
|
+
bgYellow: [43, 49],
|
|
8205
|
+
bgBlue: [44, 49],
|
|
8206
|
+
bgMagenta: [45, 49],
|
|
8207
|
+
bgCyan: [46, 49],
|
|
8208
|
+
bgWhite: [47, 49],
|
|
8209
|
+
bgBlackBright: [100, 49],
|
|
8210
|
+
bgGray: [100, 49],
|
|
8211
|
+
bgGrey: [100, 49],
|
|
8212
|
+
bgRedBright: [101, 49],
|
|
8213
|
+
bgGreenBright: [102, 49],
|
|
8214
|
+
bgYellowBright: [103, 49],
|
|
8215
|
+
bgBlueBright: [104, 49],
|
|
8216
|
+
bgMagentaBright: [105, 49],
|
|
8217
|
+
bgCyanBright: [106, 49],
|
|
8218
|
+
bgWhiteBright: [107, 49]
|
|
8219
|
+
}
|
|
8220
|
+
};
|
|
8221
|
+
Object.keys(styles$1.modifier);
|
|
8222
|
+
const foregroundColorNames = Object.keys(styles$1.color);
|
|
8223
|
+
const backgroundColorNames = Object.keys(styles$1.bgColor);
|
|
8224
|
+
[...foregroundColorNames, ...backgroundColorNames];
|
|
8225
|
+
function assembleStyles() {
|
|
8226
|
+
const codes = /* @__PURE__ */ new Map();
|
|
8227
|
+
for (const [groupName, group] of Object.entries(styles$1)) {
|
|
8228
|
+
for (const [styleName, style] of Object.entries(group)) {
|
|
8229
|
+
styles$1[styleName] = {
|
|
8230
|
+
open: `\u001B[${style[0]}m`,
|
|
8231
|
+
close: `\u001B[${style[1]}m`
|
|
8232
|
+
};
|
|
8233
|
+
group[styleName] = styles$1[styleName];
|
|
8234
|
+
codes.set(style[0], style[1]);
|
|
8235
|
+
}
|
|
8236
|
+
Object.defineProperty(styles$1, groupName, {
|
|
8237
|
+
value: group,
|
|
8238
|
+
enumerable: false
|
|
8239
|
+
});
|
|
8240
|
+
}
|
|
8241
|
+
Object.defineProperty(styles$1, "codes", {
|
|
8242
|
+
value: codes,
|
|
8243
|
+
enumerable: false
|
|
8244
|
+
});
|
|
8245
|
+
styles$1.color.close = "\x1B[39m";
|
|
8246
|
+
styles$1.bgColor.close = "\x1B[49m";
|
|
8247
|
+
styles$1.color.ansi = wrapAnsi16();
|
|
8248
|
+
styles$1.color.ansi256 = wrapAnsi256();
|
|
8249
|
+
styles$1.color.ansi16m = wrapAnsi16m();
|
|
8250
|
+
styles$1.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET);
|
|
8251
|
+
styles$1.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET);
|
|
8252
|
+
styles$1.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
|
|
8253
|
+
Object.defineProperties(styles$1, {
|
|
8254
|
+
rgbToAnsi256: {
|
|
8255
|
+
value(red, green, blue) {
|
|
8256
|
+
if (red === green && green === blue) {
|
|
8257
|
+
if (red < 8) return 16;
|
|
8258
|
+
if (red > 248) return 231;
|
|
8259
|
+
return Math.round((red - 8) / 247 * 24) + 232;
|
|
8260
|
+
}
|
|
8261
|
+
return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5);
|
|
8262
|
+
},
|
|
8263
|
+
enumerable: false
|
|
8264
|
+
},
|
|
8265
|
+
hexToRgb: {
|
|
8266
|
+
value(hex) {
|
|
8267
|
+
const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
|
|
8268
|
+
if (!matches) return [
|
|
8269
|
+
0,
|
|
8270
|
+
0,
|
|
8271
|
+
0
|
|
8272
|
+
];
|
|
8273
|
+
let [colorString] = matches;
|
|
8274
|
+
if (colorString.length === 3) colorString = [...colorString].map((character) => character + character).join("");
|
|
8275
|
+
const integer = Number.parseInt(colorString, 16);
|
|
8276
|
+
return [
|
|
8277
|
+
integer >> 16 & 255,
|
|
8278
|
+
integer >> 8 & 255,
|
|
8279
|
+
integer & 255
|
|
8280
|
+
];
|
|
8281
|
+
},
|
|
8282
|
+
enumerable: false
|
|
8283
|
+
},
|
|
8284
|
+
hexToAnsi256: {
|
|
8285
|
+
value: (hex) => styles$1.rgbToAnsi256(...styles$1.hexToRgb(hex)),
|
|
8286
|
+
enumerable: false
|
|
8287
|
+
},
|
|
8288
|
+
ansi256ToAnsi: {
|
|
8289
|
+
value(code) {
|
|
8290
|
+
if (code < 8) return 30 + code;
|
|
8291
|
+
if (code < 16) return 90 + (code - 8);
|
|
8292
|
+
let red;
|
|
8293
|
+
let green;
|
|
8294
|
+
let blue;
|
|
8295
|
+
if (code >= 232) {
|
|
8296
|
+
red = ((code - 232) * 10 + 8) / 255;
|
|
8297
|
+
green = red;
|
|
8298
|
+
blue = red;
|
|
8299
|
+
} else {
|
|
8300
|
+
code -= 16;
|
|
8301
|
+
const remainder = code % 36;
|
|
8302
|
+
red = Math.floor(code / 36) / 5;
|
|
8303
|
+
green = Math.floor(remainder / 6) / 5;
|
|
8304
|
+
blue = remainder % 6 / 5;
|
|
8305
|
+
}
|
|
8306
|
+
const value = Math.max(red, green, blue) * 2;
|
|
8307
|
+
if (value === 0) return 30;
|
|
8308
|
+
let result = 30 + (Math.round(blue) << 2 | Math.round(green) << 1 | Math.round(red));
|
|
8309
|
+
if (value === 2) result += 60;
|
|
8310
|
+
return result;
|
|
8311
|
+
},
|
|
8312
|
+
enumerable: false
|
|
8313
|
+
},
|
|
8314
|
+
rgbToAnsi: {
|
|
8315
|
+
value: (red, green, blue) => styles$1.ansi256ToAnsi(styles$1.rgbToAnsi256(red, green, blue)),
|
|
8316
|
+
enumerable: false
|
|
8317
|
+
},
|
|
8318
|
+
hexToAnsi: {
|
|
8319
|
+
value: (hex) => styles$1.ansi256ToAnsi(styles$1.hexToAnsi256(hex)),
|
|
8320
|
+
enumerable: false
|
|
8321
|
+
}
|
|
8322
|
+
});
|
|
8323
|
+
return styles$1;
|
|
8324
|
+
}
|
|
8325
|
+
const ansiStyles = assembleStyles();
|
|
8326
|
+
//#endregion
|
|
8327
|
+
//#region node_modules/chalk/source/vendor/supports-color/index.js
|
|
8328
|
+
function hasFlag(flag, argv = globalThis.Deno ? globalThis.Deno.args : process$1.argv) {
|
|
8329
|
+
const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
|
|
8330
|
+
const position = argv.indexOf(prefix + flag);
|
|
8331
|
+
const terminatorPosition = argv.indexOf("--");
|
|
8332
|
+
return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
|
|
8333
|
+
}
|
|
8334
|
+
const { env } = process$1;
|
|
8335
|
+
let flagForceColor;
|
|
8336
|
+
if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) flagForceColor = 0;
|
|
8337
|
+
else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) flagForceColor = 1;
|
|
8338
|
+
function envForceColor() {
|
|
8339
|
+
if ("FORCE_COLOR" in env) {
|
|
8340
|
+
if (env.FORCE_COLOR === "true") return 1;
|
|
8341
|
+
if (env.FORCE_COLOR === "false") return 0;
|
|
8342
|
+
return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3);
|
|
8343
|
+
}
|
|
8344
|
+
}
|
|
8345
|
+
function translateLevel(level) {
|
|
8346
|
+
if (level === 0) return false;
|
|
8347
|
+
return {
|
|
8348
|
+
level,
|
|
8349
|
+
hasBasic: true,
|
|
8350
|
+
has256: level >= 2,
|
|
8351
|
+
has16m: level >= 3
|
|
8352
|
+
};
|
|
8353
|
+
}
|
|
8354
|
+
function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
|
|
8355
|
+
const noFlagForceColor = envForceColor();
|
|
8356
|
+
if (noFlagForceColor !== void 0) flagForceColor = noFlagForceColor;
|
|
8357
|
+
const forceColor = sniffFlags ? flagForceColor : noFlagForceColor;
|
|
8358
|
+
if (forceColor === 0) return 0;
|
|
8359
|
+
if (sniffFlags) {
|
|
8360
|
+
if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) return 3;
|
|
8361
|
+
if (hasFlag("color=256")) return 2;
|
|
8362
|
+
}
|
|
8363
|
+
if ("TF_BUILD" in env && "AGENT_NAME" in env) return 1;
|
|
8364
|
+
if (haveStream && !streamIsTTY && forceColor === void 0) return 0;
|
|
8365
|
+
const min = forceColor || 0;
|
|
8366
|
+
if (env.TERM === "dumb") return min;
|
|
8367
|
+
if (process$1.platform === "win32") {
|
|
8368
|
+
const osRelease = os$1.release().split(".");
|
|
8369
|
+
if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) return Number(osRelease[2]) >= 14931 ? 3 : 2;
|
|
8370
|
+
return 1;
|
|
8371
|
+
}
|
|
8372
|
+
if ("CI" in env) {
|
|
8373
|
+
if ([
|
|
8374
|
+
"GITHUB_ACTIONS",
|
|
8375
|
+
"GITEA_ACTIONS",
|
|
8376
|
+
"CIRCLECI"
|
|
8377
|
+
].some((key) => key in env)) return 3;
|
|
8378
|
+
if ([
|
|
8379
|
+
"TRAVIS",
|
|
8380
|
+
"APPVEYOR",
|
|
8381
|
+
"GITLAB_CI",
|
|
8382
|
+
"BUILDKITE",
|
|
8383
|
+
"DRONE"
|
|
8384
|
+
].some((sign) => sign in env) || env.CI_NAME === "codeship") return 1;
|
|
8385
|
+
return min;
|
|
8386
|
+
}
|
|
8387
|
+
if ("TEAMCITY_VERSION" in env) return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
|
|
8388
|
+
if (env.COLORTERM === "truecolor") return 3;
|
|
8389
|
+
if (env.TERM === "xterm-kitty") return 3;
|
|
8390
|
+
if (env.TERM === "xterm-ghostty") return 3;
|
|
8391
|
+
if (env.TERM === "wezterm") return 3;
|
|
8392
|
+
if ("TERM_PROGRAM" in env) {
|
|
8393
|
+
const version = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
|
|
8394
|
+
switch (env.TERM_PROGRAM) {
|
|
8395
|
+
case "iTerm.app": return version >= 3 ? 3 : 2;
|
|
8396
|
+
case "Apple_Terminal": return 2;
|
|
8397
|
+
}
|
|
8398
|
+
}
|
|
8399
|
+
if (/-256(color)?$/i.test(env.TERM)) return 2;
|
|
8400
|
+
if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) return 1;
|
|
8401
|
+
if ("COLORTERM" in env) return 1;
|
|
8402
|
+
return min;
|
|
8403
|
+
}
|
|
8404
|
+
function createSupportsColor(stream, options = {}) {
|
|
8405
|
+
return translateLevel(_supportsColor(stream, {
|
|
8406
|
+
streamIsTTY: stream && stream.isTTY,
|
|
8407
|
+
...options
|
|
8408
|
+
}));
|
|
8409
|
+
}
|
|
8410
|
+
const supportsColor = {
|
|
8411
|
+
stdout: createSupportsColor({ isTTY: tty.isatty(1) }),
|
|
8412
|
+
stderr: createSupportsColor({ isTTY: tty.isatty(2) })
|
|
8413
|
+
};
|
|
8414
|
+
//#endregion
|
|
8415
|
+
//#region node_modules/chalk/source/utilities.js
|
|
8416
|
+
function stringReplaceAll(string, substring, replacer) {
|
|
8417
|
+
let index = string.indexOf(substring);
|
|
8418
|
+
if (index === -1) return string;
|
|
8419
|
+
const substringLength = substring.length;
|
|
8420
|
+
let endIndex = 0;
|
|
8421
|
+
let returnValue = "";
|
|
8422
|
+
do {
|
|
8423
|
+
returnValue += string.slice(endIndex, index) + substring + replacer;
|
|
8424
|
+
endIndex = index + substringLength;
|
|
8425
|
+
index = string.indexOf(substring, endIndex);
|
|
8426
|
+
} while (index !== -1);
|
|
8427
|
+
returnValue += string.slice(endIndex);
|
|
8428
|
+
return returnValue;
|
|
8429
|
+
}
|
|
8430
|
+
function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
|
|
8431
|
+
let endIndex = 0;
|
|
8432
|
+
let returnValue = "";
|
|
8433
|
+
do {
|
|
8434
|
+
const gotCR = string[index - 1] === "\r";
|
|
8435
|
+
returnValue += string.slice(endIndex, gotCR ? index - 1 : index) + prefix + (gotCR ? "\r\n" : "\n") + postfix;
|
|
8436
|
+
endIndex = index + 1;
|
|
8437
|
+
index = string.indexOf("\n", endIndex);
|
|
8438
|
+
} while (index !== -1);
|
|
8439
|
+
returnValue += string.slice(endIndex);
|
|
8440
|
+
return returnValue;
|
|
8441
|
+
}
|
|
8442
|
+
//#endregion
|
|
8443
|
+
//#region node_modules/chalk/source/index.js
|
|
8444
|
+
const { stdout: stdoutColor, stderr: stderrColor } = supportsColor;
|
|
8445
|
+
const GENERATOR = Symbol("GENERATOR");
|
|
8446
|
+
const STYLER = Symbol("STYLER");
|
|
8447
|
+
const IS_EMPTY = Symbol("IS_EMPTY");
|
|
8448
|
+
const levelMapping = [
|
|
8449
|
+
"ansi",
|
|
8450
|
+
"ansi",
|
|
8451
|
+
"ansi256",
|
|
8452
|
+
"ansi16m"
|
|
8453
|
+
];
|
|
8454
|
+
const styles = Object.create(null);
|
|
8455
|
+
const applyOptions = (object, options = {}) => {
|
|
8456
|
+
if (options.level && !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)) throw new Error("The `level` option should be an integer from 0 to 3");
|
|
8457
|
+
const colorLevel = stdoutColor ? stdoutColor.level : 0;
|
|
8458
|
+
object.level = options.level === void 0 ? colorLevel : options.level;
|
|
8459
|
+
};
|
|
8460
|
+
const chalkFactory = (options) => {
|
|
8461
|
+
const chalk = (...strings) => strings.join(" ");
|
|
8462
|
+
applyOptions(chalk, options);
|
|
8463
|
+
Object.setPrototypeOf(chalk, createChalk.prototype);
|
|
8464
|
+
return chalk;
|
|
8465
|
+
};
|
|
8466
|
+
function createChalk(options) {
|
|
8467
|
+
return chalkFactory(options);
|
|
8468
|
+
}
|
|
8469
|
+
Object.setPrototypeOf(createChalk.prototype, Function.prototype);
|
|
8470
|
+
for (const [styleName, style] of Object.entries(ansiStyles)) styles[styleName] = { get() {
|
|
8471
|
+
const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]);
|
|
8472
|
+
Object.defineProperty(this, styleName, { value: builder });
|
|
8473
|
+
return builder;
|
|
8474
|
+
} };
|
|
8475
|
+
styles.visible = { get() {
|
|
8476
|
+
const builder = createBuilder(this, this[STYLER], true);
|
|
8477
|
+
Object.defineProperty(this, "visible", { value: builder });
|
|
8478
|
+
return builder;
|
|
8479
|
+
} };
|
|
8480
|
+
const getModelAnsi = (model, level, type, ...arguments_) => {
|
|
8481
|
+
if (model === "rgb") {
|
|
8482
|
+
if (level === "ansi16m") return ansiStyles[type].ansi16m(...arguments_);
|
|
8483
|
+
if (level === "ansi256") return ansiStyles[type].ansi256(ansiStyles.rgbToAnsi256(...arguments_));
|
|
8484
|
+
return ansiStyles[type].ansi(ansiStyles.rgbToAnsi(...arguments_));
|
|
8485
|
+
}
|
|
8486
|
+
if (model === "hex") return getModelAnsi("rgb", level, type, ...ansiStyles.hexToRgb(...arguments_));
|
|
8487
|
+
return ansiStyles[type][model](...arguments_);
|
|
8488
|
+
};
|
|
8489
|
+
for (const model of [
|
|
8490
|
+
"rgb",
|
|
8491
|
+
"hex",
|
|
8492
|
+
"ansi256"
|
|
8493
|
+
]) {
|
|
8494
|
+
styles[model] = { get() {
|
|
8495
|
+
const { level } = this;
|
|
8496
|
+
return function(...arguments_) {
|
|
8497
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], "color", ...arguments_), ansiStyles.color.close, this[STYLER]);
|
|
8498
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
8499
|
+
};
|
|
8500
|
+
} };
|
|
8501
|
+
const bgModel = "bg" + model[0].toUpperCase() + model.slice(1);
|
|
8502
|
+
styles[bgModel] = { get() {
|
|
8503
|
+
const { level } = this;
|
|
8504
|
+
return function(...arguments_) {
|
|
8505
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], "bgColor", ...arguments_), ansiStyles.bgColor.close, this[STYLER]);
|
|
8506
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
8507
|
+
};
|
|
8508
|
+
} };
|
|
8509
|
+
}
|
|
8510
|
+
const proto = Object.defineProperties(() => {}, {
|
|
8511
|
+
...styles,
|
|
8512
|
+
level: {
|
|
8513
|
+
enumerable: true,
|
|
8514
|
+
get() {
|
|
8515
|
+
return this[GENERATOR].level;
|
|
8516
|
+
},
|
|
8517
|
+
set(level) {
|
|
8518
|
+
this[GENERATOR].level = level;
|
|
8519
|
+
}
|
|
8520
|
+
}
|
|
8521
|
+
});
|
|
8522
|
+
const createStyler = (open, close, parent) => {
|
|
8523
|
+
let openAll;
|
|
8524
|
+
let closeAll;
|
|
8525
|
+
if (parent === void 0) {
|
|
8526
|
+
openAll = open;
|
|
8527
|
+
closeAll = close;
|
|
8528
|
+
} else {
|
|
8529
|
+
openAll = parent.openAll + open;
|
|
8530
|
+
closeAll = close + parent.closeAll;
|
|
8531
|
+
}
|
|
8532
|
+
return {
|
|
8533
|
+
open,
|
|
8534
|
+
close,
|
|
8535
|
+
openAll,
|
|
8536
|
+
closeAll,
|
|
8537
|
+
parent
|
|
8538
|
+
};
|
|
8539
|
+
};
|
|
8540
|
+
const createBuilder = (self, _styler, _isEmpty) => {
|
|
8541
|
+
const builder = (...arguments_) => applyStyle(builder, arguments_.length === 1 ? "" + arguments_[0] : arguments_.join(" "));
|
|
8542
|
+
Object.setPrototypeOf(builder, proto);
|
|
8543
|
+
builder[GENERATOR] = self;
|
|
8544
|
+
builder[STYLER] = _styler;
|
|
8545
|
+
builder[IS_EMPTY] = _isEmpty;
|
|
8546
|
+
return builder;
|
|
8547
|
+
};
|
|
8548
|
+
const applyStyle = (self, string) => {
|
|
8549
|
+
if (self.level <= 0 || !string) return self[IS_EMPTY] ? "" : string;
|
|
8550
|
+
let styler = self[STYLER];
|
|
8551
|
+
if (styler === void 0) return string;
|
|
8552
|
+
const { openAll, closeAll } = styler;
|
|
8553
|
+
if (string.includes("\x1B")) while (styler !== void 0) {
|
|
8554
|
+
string = stringReplaceAll(string, styler.close, styler.open);
|
|
8555
|
+
styler = styler.parent;
|
|
8556
|
+
}
|
|
8557
|
+
const lfIndex = string.indexOf("\n");
|
|
8558
|
+
if (lfIndex !== -1) string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
|
|
8559
|
+
return openAll + string + closeAll;
|
|
8560
|
+
};
|
|
8561
|
+
Object.defineProperties(createChalk.prototype, styles);
|
|
8562
|
+
const chalk = createChalk();
|
|
8563
|
+
createChalk({ level: stderrColor ? stderrColor.level : 0 });
|
|
8564
|
+
//#endregion
|
|
8565
|
+
//#region node_modules/tiny-typescript-logger/dist/index.js
|
|
8566
|
+
var logLevelToName = {
|
|
8567
|
+
[10]: "trace",
|
|
8568
|
+
[20]: "debug",
|
|
8569
|
+
[30]: "info",
|
|
8570
|
+
[40]: "warn",
|
|
8571
|
+
[50]: "error",
|
|
8572
|
+
[60]: "fatal"
|
|
8573
|
+
};
|
|
8574
|
+
var logLevelToColor = {
|
|
8575
|
+
[10]: "#ECD5E3",
|
|
8576
|
+
[20]: "#ABDEE6",
|
|
8577
|
+
[30]: "#97C1A9",
|
|
8578
|
+
[40]: "#FDFD96",
|
|
8579
|
+
[50]: "#FFB6B6",
|
|
8580
|
+
[60]: "#FF6961"
|
|
8581
|
+
};
|
|
8582
|
+
var Logger = class {
|
|
8583
|
+
debug(...items) {
|
|
8584
|
+
this.log(20, ...items);
|
|
8585
|
+
}
|
|
8586
|
+
error(...items) {
|
|
8587
|
+
this.log(50, ...items);
|
|
8588
|
+
}
|
|
8589
|
+
fatal(...items) {
|
|
8590
|
+
this.log(60, ...items);
|
|
8591
|
+
}
|
|
8592
|
+
info(...items) {
|
|
8593
|
+
this.log(30, ...items);
|
|
8594
|
+
}
|
|
8595
|
+
log(level, ...items) {
|
|
8596
|
+
if (items.length === 0) return;
|
|
8597
|
+
const message = this.isString(items[0]) ? items.shift() : "";
|
|
8598
|
+
const formattedMessage = this.formatMessage(level, message);
|
|
8599
|
+
console.log(formattedMessage, ...items);
|
|
8600
|
+
}
|
|
8601
|
+
trace(...items) {
|
|
8602
|
+
this.log(10, ...items);
|
|
8603
|
+
}
|
|
8604
|
+
warn(...items) {
|
|
8605
|
+
this.log(40, ...items);
|
|
8606
|
+
}
|
|
8607
|
+
formatMessage(level, message) {
|
|
8608
|
+
const date = (/* @__PURE__ */ new Date()).toISOString();
|
|
8609
|
+
const formattedDate = chalk.dim(date);
|
|
8610
|
+
const logLevelName = logLevelToName[level] ?? "unknown";
|
|
8611
|
+
const logLevelColor = logLevelToColor[level] ?? "#f1f5f9";
|
|
8612
|
+
return [
|
|
8613
|
+
`[${chalk.hex(logLevelColor)(logLevelName.toUpperCase())}]`,
|
|
8614
|
+
formattedDate,
|
|
8615
|
+
message
|
|
8616
|
+
].join(" ");
|
|
8617
|
+
}
|
|
8618
|
+
isString(value) {
|
|
8619
|
+
return typeof value === "string";
|
|
8620
|
+
}
|
|
8621
|
+
};
|
|
8622
|
+
var log = new Logger();
|
|
8623
|
+
//#endregion
|
|
8624
|
+
//#region src/services/settings.ts
|
|
8625
|
+
const tmdbApiKeySettingKey = "tmdbApiKey";
|
|
8626
|
+
const scanScheduleSettingKey = "scanSchedule";
|
|
8627
|
+
const scanScheduleValues = [
|
|
8628
|
+
"off",
|
|
8629
|
+
"6h",
|
|
8630
|
+
"12h",
|
|
8631
|
+
"24h"
|
|
8632
|
+
];
|
|
8633
|
+
function isScanScheduleValue(value) {
|
|
8634
|
+
return scanScheduleValues.includes(value);
|
|
8635
|
+
}
|
|
8636
|
+
/**
|
|
8637
|
+
* Read a setting value from the DB. Returns null when the key is not set.
|
|
8638
|
+
*
|
|
8639
|
+
* Accepts an optional database instance so tests and scripts can pass an
|
|
8640
|
+
* in-memory SQLite instance instead of the singleton.
|
|
8641
|
+
*/
|
|
8642
|
+
async function getSetting(key, database = db) {
|
|
8643
|
+
const [row] = await database.select().from(settings).where(eq(settings.key, key)).limit(1);
|
|
8644
|
+
return row?.value ?? null;
|
|
8645
|
+
}
|
|
8646
|
+
/**
|
|
8647
|
+
* Upsert a setting value.
|
|
8648
|
+
*
|
|
8649
|
+
* Uses a single INSERT ... ON CONFLICT so two concurrent callers don't both
|
|
8650
|
+
* read null, both INSERT, and fail with a UNIQUE constraint on the second.
|
|
8651
|
+
*/
|
|
8652
|
+
async function setSetting(key, value, database = db) {
|
|
8653
|
+
const now = Date.now();
|
|
8654
|
+
await database.insert(settings).values({
|
|
8655
|
+
key,
|
|
8656
|
+
value,
|
|
8657
|
+
updatedAt: now
|
|
8658
|
+
}).onConflictDoUpdate({
|
|
8659
|
+
target: settings.key,
|
|
8660
|
+
set: {
|
|
8661
|
+
value,
|
|
8662
|
+
updatedAt: now
|
|
8663
|
+
}
|
|
8664
|
+
});
|
|
8665
|
+
}
|
|
8666
|
+
/**
|
|
8667
|
+
* Resolve the TMDB API key using the dual-source strategy:
|
|
8668
|
+
*
|
|
8669
|
+
* 1. Prefer the value stored in the `settings` table.
|
|
8670
|
+
* 2. Fall back to `~/.jukebox/config.json` for backward compatibility on the
|
|
8671
|
+
* first boot after upgrading. Copy the JSON value into the DB once so
|
|
8672
|
+
* subsequent lookups only touch the DB.
|
|
8673
|
+
*
|
|
8674
|
+
* Returns null when neither source has a key configured.
|
|
8675
|
+
*/
|
|
8676
|
+
async function getTmdbApiKey(database = db) {
|
|
8677
|
+
const stored = await getSetting(tmdbApiKeySettingKey, database);
|
|
8678
|
+
if (stored !== null && stored.length > 0) return stored;
|
|
8679
|
+
const legacyKey = getConfig()?.tmdbApiKey ?? null;
|
|
8680
|
+
if (legacyKey !== null && legacyKey.length > 0) {
|
|
8681
|
+
await setSetting(tmdbApiKeySettingKey, legacyKey, database);
|
|
8682
|
+
return legacyKey;
|
|
8683
|
+
}
|
|
8684
|
+
return null;
|
|
8685
|
+
}
|
|
8686
|
+
/**
|
|
8687
|
+
* Persist the TMDB API key to the DB. Also mirror the value to the JSON
|
|
8688
|
+
* config file so rollbacks to an older server build keep working.
|
|
8689
|
+
*/
|
|
8690
|
+
async function setTmdbApiKey(apiKey, database = db) {
|
|
8691
|
+
await setSetting(tmdbApiKeySettingKey, apiKey, database);
|
|
8692
|
+
try {
|
|
8693
|
+
await saveConfig({ tmdbApiKey: apiKey });
|
|
8694
|
+
} catch (error) {
|
|
8695
|
+
log.warn("Couldn't mirror TMDB key to config.json — rollback safety is degraded. Check permissions on ~/.jukebox/.", error);
|
|
8696
|
+
}
|
|
8697
|
+
}
|
|
8698
|
+
//#endregion
|
|
7819
8699
|
//#region src/services/tmdb.ts
|
|
7820
8700
|
const tmdbBaseUrl = "https://api.themoviedb.org/3";
|
|
7821
|
-
function getApiKey() {
|
|
7822
|
-
const apiKey =
|
|
8701
|
+
async function getApiKey() {
|
|
8702
|
+
const apiKey = await getTmdbApiKey() ?? process.env.TMDB_API_KEY ?? null;
|
|
7823
8703
|
if (!apiKey) throw new Error("TMDB API key is not configured");
|
|
7824
8704
|
return apiKey;
|
|
7825
8705
|
}
|
|
7826
8706
|
async function searchMovie(title, year) {
|
|
7827
|
-
const apiKey = getApiKey();
|
|
8707
|
+
const apiKey = await getApiKey();
|
|
7828
8708
|
const params = new URLSearchParams({
|
|
7829
8709
|
api_key: apiKey,
|
|
7830
8710
|
query: title
|
|
@@ -7835,14 +8715,14 @@ async function searchMovie(title, year) {
|
|
|
7835
8715
|
return (await response.json()).results;
|
|
7836
8716
|
}
|
|
7837
8717
|
async function getMovieDetails(tmdbId) {
|
|
7838
|
-
const apiKey = getApiKey();
|
|
8718
|
+
const apiKey = await getApiKey();
|
|
7839
8719
|
const params = new URLSearchParams({ api_key: apiKey });
|
|
7840
8720
|
const response = await fetch(`${tmdbBaseUrl}/movie/${tmdbId}?${params.toString()}`);
|
|
7841
8721
|
if (!response.ok) throw new Error(`TMDB movie details failed: ${response.statusText}`);
|
|
7842
8722
|
return await response.json();
|
|
7843
8723
|
}
|
|
7844
8724
|
async function getMovieVideos(tmdbId) {
|
|
7845
|
-
const apiKey = getApiKey();
|
|
8725
|
+
const apiKey = await getApiKey();
|
|
7846
8726
|
const params = new URLSearchParams({ api_key: apiKey });
|
|
7847
8727
|
const response = await fetch(`${tmdbBaseUrl}/movie/${tmdbId}/videos?${params.toString()}`);
|
|
7848
8728
|
if (!response.ok) throw new Error(`TMDB movie videos failed: ${response.statusText}`);
|
|
@@ -7863,7 +8743,7 @@ function getTrailerUrl(videos) {
|
|
|
7863
8743
|
}
|
|
7864
8744
|
async function fetchMovieMetadata(title, year) {
|
|
7865
8745
|
try {
|
|
7866
|
-
getApiKey();
|
|
8746
|
+
await getApiKey();
|
|
7867
8747
|
} catch {
|
|
7868
8748
|
return null;
|
|
7869
8749
|
}
|
|
@@ -7886,7 +8766,7 @@ async function fetchMovieMetadata(title, year) {
|
|
|
7886
8766
|
};
|
|
7887
8767
|
}
|
|
7888
8768
|
async function searchShow(title, year) {
|
|
7889
|
-
const apiKey = getApiKey();
|
|
8769
|
+
const apiKey = await getApiKey();
|
|
7890
8770
|
const params = new URLSearchParams({
|
|
7891
8771
|
api_key: apiKey,
|
|
7892
8772
|
query: title
|
|
@@ -7897,14 +8777,14 @@ async function searchShow(title, year) {
|
|
|
7897
8777
|
return (await response.json()).results;
|
|
7898
8778
|
}
|
|
7899
8779
|
async function getShowDetails(tmdbId) {
|
|
7900
|
-
const apiKey = getApiKey();
|
|
8780
|
+
const apiKey = await getApiKey();
|
|
7901
8781
|
const params = new URLSearchParams({ api_key: apiKey });
|
|
7902
8782
|
const response = await fetch(`${tmdbBaseUrl}/tv/${tmdbId}?${params.toString()}`);
|
|
7903
8783
|
if (!response.ok) throw new Error(`TMDB show details failed: ${response.statusText}`);
|
|
7904
8784
|
return await response.json();
|
|
7905
8785
|
}
|
|
7906
8786
|
async function getSeasonDetails(tmdbId, seasonNumber) {
|
|
7907
|
-
const apiKey = getApiKey();
|
|
8787
|
+
const apiKey = await getApiKey();
|
|
7908
8788
|
const params = new URLSearchParams({ api_key: apiKey });
|
|
7909
8789
|
const response = await fetch(`${tmdbBaseUrl}/tv/${tmdbId}/season/${seasonNumber}?${params.toString()}`);
|
|
7910
8790
|
if (!response.ok) throw new Error(`TMDB season details failed: ${response.statusText}`);
|
|
@@ -7912,7 +8792,7 @@ async function getSeasonDetails(tmdbId, seasonNumber) {
|
|
|
7912
8792
|
}
|
|
7913
8793
|
async function fetchShowMetadata(title, year) {
|
|
7914
8794
|
try {
|
|
7915
|
-
getApiKey();
|
|
8795
|
+
await getApiKey();
|
|
7916
8796
|
} catch {
|
|
7917
8797
|
return null;
|
|
7918
8798
|
}
|
|
@@ -7935,7 +8815,7 @@ async function fetchShowMetadata(title, year) {
|
|
|
7935
8815
|
}
|
|
7936
8816
|
async function fetchSeasonMetadata(tmdbId, seasonNumber) {
|
|
7937
8817
|
try {
|
|
7938
|
-
getApiKey();
|
|
8818
|
+
await getApiKey();
|
|
7939
8819
|
} catch {
|
|
7940
8820
|
return null;
|
|
7941
8821
|
}
|
|
@@ -7955,106 +8835,73 @@ async function fetchSeasonMetadata(tmdbId, seasonNumber) {
|
|
|
7955
8835
|
};
|
|
7956
8836
|
}
|
|
7957
8837
|
//#endregion
|
|
7958
|
-
//#region src/services/
|
|
8838
|
+
//#region src/services/subtitle-sync.ts
|
|
7959
8839
|
/**
|
|
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 }
|
|
8840
|
+
* Replace the subtitle rows attached to a movie with the freshly discovered
|
|
8841
|
+
* sidecars. We delete-and-reinsert rather than diffing so a renamed or
|
|
8842
|
+
* removed sidecar disappears on the next scan without bookkeeping.
|
|
7966
8843
|
*/
|
|
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;
|
|
8844
|
+
async function syncSubtitlesForMovie(movieId, discovered) {
|
|
8845
|
+
await db.delete(subtitles).where(eq(subtitles.movieId, movieId));
|
|
8846
|
+
if (discovered.length === 0) return;
|
|
8847
|
+
const rows = discovered.map((subtitle) => ({
|
|
8848
|
+
movieId,
|
|
8849
|
+
episodeId: null,
|
|
8850
|
+
filePath: subtitle.filePath,
|
|
8851
|
+
format: subtitle.format,
|
|
8852
|
+
language: subtitle.language
|
|
8853
|
+
}));
|
|
8854
|
+
await db.insert(subtitles).values(rows);
|
|
8017
8855
|
}
|
|
8018
8856
|
/**
|
|
8019
|
-
*
|
|
8857
|
+
* Same as syncSubtitlesForMovie but for episodes. See note above.
|
|
8020
8858
|
*/
|
|
8021
|
-
function
|
|
8022
|
-
|
|
8859
|
+
async function syncSubtitlesForEpisode(episodeId, discovered) {
|
|
8860
|
+
await db.delete(subtitles).where(eq(subtitles.episodeId, episodeId));
|
|
8861
|
+
if (discovered.length === 0) return;
|
|
8862
|
+
const rows = discovered.map((subtitle) => ({
|
|
8863
|
+
movieId: null,
|
|
8864
|
+
episodeId,
|
|
8865
|
+
filePath: subtitle.filePath,
|
|
8866
|
+
format: subtitle.format,
|
|
8867
|
+
language: subtitle.language
|
|
8868
|
+
}));
|
|
8869
|
+
await db.insert(subtitles).values(rows);
|
|
8023
8870
|
}
|
|
8024
8871
|
//#endregion
|
|
8025
8872
|
//#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
8873
|
/**
|
|
8039
|
-
* Recursively scan a directory for video files
|
|
8874
|
+
* Recursively scan a directory for video files. Each yielded entry includes
|
|
8875
|
+
* the subtitle siblings sitting in the same folder so subtitle discovery
|
|
8876
|
+
* piggybacks on the directory read instead of re-globbing per file.
|
|
8040
8877
|
*/
|
|
8041
8878
|
async function* scanDirectory(dir) {
|
|
8042
8879
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
8880
|
+
const videoFiles = [];
|
|
8881
|
+
const subtitleSiblings = [];
|
|
8882
|
+
const subdirectories = [];
|
|
8043
8883
|
for (const entry of entries) {
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
const ext = extname(entry.name).toLowerCase();
|
|
8048
|
-
if (VIDEO_EXTENSIONS.has(ext)) yield fullPath;
|
|
8884
|
+
if (entry.isDirectory()) {
|
|
8885
|
+
subdirectories.push(entry.name);
|
|
8886
|
+
continue;
|
|
8049
8887
|
}
|
|
8050
|
-
|
|
8888
|
+
if (!entry.isFile()) continue;
|
|
8889
|
+
const extension = extname(entry.name).toLowerCase();
|
|
8890
|
+
if (videoExtensions$1.has(extension)) videoFiles.push(entry.name);
|
|
8891
|
+
else if (subtitleExtensions.has(extension)) subtitleSiblings.push(entry.name);
|
|
8892
|
+
}
|
|
8893
|
+
for (const videoFile of videoFiles) yield {
|
|
8894
|
+
filePath: join(dir, videoFile),
|
|
8895
|
+
subtitleSiblings
|
|
8896
|
+
};
|
|
8897
|
+
for (const subdirectory of subdirectories) yield* scanDirectory(join(dir, subdirectory));
|
|
8051
8898
|
}
|
|
8052
8899
|
async function scanLibrary(libraryPath, onProgress) {
|
|
8053
8900
|
let added = 0;
|
|
8054
8901
|
let updated = 0;
|
|
8055
8902
|
let total = 0;
|
|
8056
8903
|
console.log(`Scanning: ${libraryPath}`);
|
|
8057
|
-
for await (const filePath of scanDirectory(libraryPath)) {
|
|
8904
|
+
for await (const { filePath, subtitleSiblings } of scanDirectory(libraryPath)) {
|
|
8058
8905
|
total++;
|
|
8059
8906
|
const fileName = basename(filePath);
|
|
8060
8907
|
const ext = extname(fileName).toLowerCase();
|
|
@@ -8065,9 +8912,12 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
8065
8912
|
fileSize = (await stat(filePath)).size;
|
|
8066
8913
|
} catch {}
|
|
8067
8914
|
const now = /* @__PURE__ */ new Date();
|
|
8915
|
+
const discoveredSubtitles = discoverSubtitlesForVideo(filePath, subtitleSiblings);
|
|
8068
8916
|
const existing = await db.select().from(movies).where(eq(movies.filePath, filePath)).limit(1);
|
|
8917
|
+
let movieId;
|
|
8069
8918
|
if (existing.length > 0 && existing[0]) {
|
|
8070
8919
|
const movie = existing[0];
|
|
8920
|
+
movieId = movie.id;
|
|
8071
8921
|
let tmdbData = {};
|
|
8072
8922
|
if (!movie.tmdbId) {
|
|
8073
8923
|
console.log(` Fetching TMDB metadata for: ${title}`);
|
|
@@ -8125,7 +8975,7 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
8125
8975
|
backdropPath: metadata?.backdropPath ?? null,
|
|
8126
8976
|
trailerUrl: metadata?.trailerUrl ?? null
|
|
8127
8977
|
};
|
|
8128
|
-
await db.insert(movies).values(newMovie);
|
|
8978
|
+
movieId = (await db.insert(movies).values(newMovie).returning({ id: movies.id }))[0]?.id ?? null;
|
|
8129
8979
|
added++;
|
|
8130
8980
|
await onProgress?.({
|
|
8131
8981
|
added,
|
|
@@ -8133,6 +8983,7 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
8133
8983
|
updated
|
|
8134
8984
|
});
|
|
8135
8985
|
}
|
|
8986
|
+
if (movieId !== null) await syncSubtitlesForMovie(movieId, discoveredSubtitles);
|
|
8136
8987
|
console.log(` Found: ${title}`);
|
|
8137
8988
|
}
|
|
8138
8989
|
return {
|
|
@@ -8143,7 +8994,7 @@ async function scanLibrary(libraryPath, onProgress) {
|
|
|
8143
8994
|
}
|
|
8144
8995
|
//#endregion
|
|
8145
8996
|
//#region src/services/episode-parser.ts
|
|
8146
|
-
const videoExtensions
|
|
8997
|
+
const videoExtensions = new Set([
|
|
8147
8998
|
".mp4",
|
|
8148
8999
|
".mkv",
|
|
8149
9000
|
".avi",
|
|
@@ -8159,7 +9010,7 @@ const episodePattern = /S(\d+)[Ee](\d+)/;
|
|
|
8159
9010
|
const technicalPatterns = /\b(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|WEB-DL|HDTV|DVDRip|x264|x265|H\.?264|HEVC|AAC|AC3|DD5|10bit)\b/i;
|
|
8160
9011
|
function parseEpisodeFilename(fileName) {
|
|
8161
9012
|
const ext = extname(fileName).toLowerCase();
|
|
8162
|
-
if (!videoExtensions
|
|
9013
|
+
if (!videoExtensions.has(ext)) return null;
|
|
8163
9014
|
const name = fileName.replace(/\.[^.]+$/, "");
|
|
8164
9015
|
const match = name.match(episodePattern);
|
|
8165
9016
|
if (!match?.[1] || !match[2]) return null;
|
|
@@ -8253,18 +9104,6 @@ function parseSeasonFolder(folderName) {
|
|
|
8253
9104
|
}
|
|
8254
9105
|
//#endregion
|
|
8255
9106
|
//#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
9107
|
async function scanEpisodesInDirectory(directory) {
|
|
8269
9108
|
let entries;
|
|
8270
9109
|
try {
|
|
@@ -8272,10 +9111,16 @@ async function scanEpisodesInDirectory(directory) {
|
|
|
8272
9111
|
} catch {
|
|
8273
9112
|
return [];
|
|
8274
9113
|
}
|
|
8275
|
-
const
|
|
9114
|
+
const subtitleSiblings = [];
|
|
9115
|
+
const videoEntries = [];
|
|
8276
9116
|
for (const entry of entries) {
|
|
8277
9117
|
const extension = extname(entry).toLowerCase();
|
|
8278
|
-
if (
|
|
9118
|
+
if (videoExtensions$1.has(extension)) videoEntries.push(entry);
|
|
9119
|
+
else if (subtitleExtensions.has(extension)) subtitleSiblings.push(entry);
|
|
9120
|
+
}
|
|
9121
|
+
const episodes = [];
|
|
9122
|
+
for (const entry of videoEntries) {
|
|
9123
|
+
const extension = extname(entry).toLowerCase();
|
|
8279
9124
|
const parsed = parseEpisodeFilename(entry);
|
|
8280
9125
|
if (!parsed) continue;
|
|
8281
9126
|
const filePath = join(directory, entry);
|
|
@@ -8290,7 +9135,8 @@ async function scanEpisodesInDirectory(directory) {
|
|
|
8290
9135
|
extension,
|
|
8291
9136
|
seasonNumber: parsed.seasonNumber,
|
|
8292
9137
|
episodeNumber: parsed.episodeNumber,
|
|
8293
|
-
title: parsed.title
|
|
9138
|
+
title: parsed.title,
|
|
9139
|
+
subtitleSiblings
|
|
8294
9140
|
});
|
|
8295
9141
|
}
|
|
8296
9142
|
return episodes;
|
|
@@ -8450,7 +9296,9 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
8450
9296
|
const episodeTitle = tmdbEpisode?.title ?? scannedEpisode.title ?? `Episode ${scannedEpisode.episodeNumber}`;
|
|
8451
9297
|
const existingEpisodes = await db.select().from(episodes).where(eq(episodes.filePath, scannedEpisode.filePath)).limit(1);
|
|
8452
9298
|
const now = /* @__PURE__ */ new Date();
|
|
8453
|
-
|
|
9299
|
+
let episodeId;
|
|
9300
|
+
if (existingEpisodes.length > 0 && existingEpisodes[0]) {
|
|
9301
|
+
episodeId = existingEpisodes[0].id;
|
|
8454
9302
|
await db.update(episodes).set({
|
|
8455
9303
|
title: episodeTitle,
|
|
8456
9304
|
fileName: scannedEpisode.fileName,
|
|
@@ -8482,7 +9330,7 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
8482
9330
|
createdAt: now,
|
|
8483
9331
|
updatedAt: now
|
|
8484
9332
|
};
|
|
8485
|
-
await db.insert(episodes).values(newEpisode);
|
|
9333
|
+
episodeId = (await db.insert(episodes).values(newEpisode).returning({ id: episodes.id }))[0]?.id ?? null;
|
|
8486
9334
|
added++;
|
|
8487
9335
|
await onProgress?.({
|
|
8488
9336
|
added,
|
|
@@ -8490,6 +9338,10 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
8490
9338
|
updated
|
|
8491
9339
|
});
|
|
8492
9340
|
}
|
|
9341
|
+
if (episodeId !== null) {
|
|
9342
|
+
const discoveredSubtitles = discoverSubtitlesForVideo(scannedEpisode.filePath, scannedEpisode.subtitleSiblings);
|
|
9343
|
+
await syncSubtitlesForEpisode(episodeId, discoveredSubtitles);
|
|
9344
|
+
}
|
|
8493
9345
|
}
|
|
8494
9346
|
}
|
|
8495
9347
|
console.log(` Found show: ${name} (${episodes$1.length} episodes)`);
|
|
@@ -8501,6 +9353,178 @@ async function scanShowLibrary(libraryPath, onProgress) {
|
|
|
8501
9353
|
};
|
|
8502
9354
|
}
|
|
8503
9355
|
//#endregion
|
|
9356
|
+
//#region src/services/scan-manager.ts
|
|
9357
|
+
const interruptedMessage = "Server restarted mid-scan. Run a manual scan to resume.";
|
|
9358
|
+
function createScanManager(dependencies = {}) {
|
|
9359
|
+
const database = dependencies.database ?? db;
|
|
9360
|
+
const scanLibrary$1 = dependencies.scanLibrary ?? scanLibrary;
|
|
9361
|
+
const scanShowLibrary$1 = dependencies.scanShowLibrary ?? scanShowLibrary;
|
|
9362
|
+
const emitter = new EventEmitter();
|
|
9363
|
+
emitter.setMaxListeners(0);
|
|
9364
|
+
let currentJob = null;
|
|
9365
|
+
async function getStatus() {
|
|
9366
|
+
if (currentJob) {
|
|
9367
|
+
const [latest] = await database.select().from(scanJobs).where(eq(scanJobs.id, currentJob.id)).limit(1);
|
|
9368
|
+
return {
|
|
9369
|
+
currentJob: latest ?? currentJob,
|
|
9370
|
+
isRunning: true,
|
|
9371
|
+
lastJob: latest ?? currentJob
|
|
9372
|
+
};
|
|
9373
|
+
}
|
|
9374
|
+
const [last] = await database.select().from(scanJobs).orderBy(desc(scanJobs.startedAt)).limit(1);
|
|
9375
|
+
return {
|
|
9376
|
+
currentJob: null,
|
|
9377
|
+
isRunning: false,
|
|
9378
|
+
lastJob: last ?? null
|
|
9379
|
+
};
|
|
9380
|
+
}
|
|
9381
|
+
function isRunning() {
|
|
9382
|
+
return currentJob !== null;
|
|
9383
|
+
}
|
|
9384
|
+
async function recoverInterruptedJobs() {
|
|
9385
|
+
const now = /* @__PURE__ */ new Date();
|
|
9386
|
+
const running = await database.select().from(scanJobs).where(eq(scanJobs.status, "running"));
|
|
9387
|
+
if (running.length === 0) return;
|
|
9388
|
+
for (const job of running) await database.update(scanJobs).set({
|
|
9389
|
+
endedAt: now,
|
|
9390
|
+
errorMessage: interruptedMessage,
|
|
9391
|
+
status: "error"
|
|
9392
|
+
}).where(eq(scanJobs.id, job.id));
|
|
9393
|
+
log.info(`Marked ${running.length} interrupted scan job(s) as failed after a restart.`);
|
|
9394
|
+
}
|
|
9395
|
+
async function start() {
|
|
9396
|
+
if (currentJob) return { status: "already-running" };
|
|
9397
|
+
const libraries$4 = await database.select().from(libraries);
|
|
9398
|
+
if (libraries$4.length === 0) return {
|
|
9399
|
+
status: "no-libraries",
|
|
9400
|
+
message: "No libraries configured. Add a library in Settings before scanning."
|
|
9401
|
+
};
|
|
9402
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
9403
|
+
const [inserted] = await database.insert(scanJobs).values({
|
|
9404
|
+
startedAt,
|
|
9405
|
+
status: "running",
|
|
9406
|
+
added: 0,
|
|
9407
|
+
updated: 0,
|
|
9408
|
+
total: 0
|
|
9409
|
+
}).returning();
|
|
9410
|
+
invariant(inserted, "Failed to create scan_jobs row.");
|
|
9411
|
+
currentJob = inserted;
|
|
9412
|
+
emitter.emit("job-started", {
|
|
9413
|
+
jobId: inserted.id,
|
|
9414
|
+
startedAt: inserted.startedAt
|
|
9415
|
+
});
|
|
9416
|
+
let totalAdded = 0;
|
|
9417
|
+
let totalUpdated = 0;
|
|
9418
|
+
let totalFound = 0;
|
|
9419
|
+
const libraryErrors = [];
|
|
9420
|
+
try {
|
|
9421
|
+
for (let index = 0; index < libraries$4.length; index++) {
|
|
9422
|
+
const library = libraries$4[index];
|
|
9423
|
+
if (!library) continue;
|
|
9424
|
+
const libraryType = library.type === "shows" ? "shows" : "movies";
|
|
9425
|
+
emitter.emit("library-start", {
|
|
9426
|
+
index,
|
|
9427
|
+
libraryId: library.id,
|
|
9428
|
+
name: library.name,
|
|
9429
|
+
type: libraryType
|
|
9430
|
+
});
|
|
9431
|
+
const onProgress = async (progress) => {
|
|
9432
|
+
const aggregate = {
|
|
9433
|
+
added: totalAdded + progress.added,
|
|
9434
|
+
total: totalFound + progress.total,
|
|
9435
|
+
updated: totalUpdated + progress.updated
|
|
9436
|
+
};
|
|
9437
|
+
await database.update(scanJobs).set(aggregate).where(eq(scanJobs.id, inserted.id));
|
|
9438
|
+
emitter.emit("file-scanned", {
|
|
9439
|
+
...progress,
|
|
9440
|
+
index,
|
|
9441
|
+
libraryId: library.id
|
|
9442
|
+
});
|
|
9443
|
+
};
|
|
9444
|
+
try {
|
|
9445
|
+
const result = libraryType === "shows" ? await scanShowLibrary$1(library.path, onProgress) : await scanLibrary$1(library.path, onProgress);
|
|
9446
|
+
totalAdded += result.added;
|
|
9447
|
+
totalUpdated += result.updated;
|
|
9448
|
+
totalFound += result.total;
|
|
9449
|
+
emitter.emit("library-complete", {
|
|
9450
|
+
...result,
|
|
9451
|
+
index,
|
|
9452
|
+
libraryId: library.id
|
|
9453
|
+
});
|
|
9454
|
+
} catch (caughtError) {
|
|
9455
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9456
|
+
libraryErrors.push(`${library.name} — ${message}`);
|
|
9457
|
+
emitter.emit("library-error", {
|
|
9458
|
+
error: message,
|
|
9459
|
+
index,
|
|
9460
|
+
libraryId: library.id
|
|
9461
|
+
});
|
|
9462
|
+
}
|
|
9463
|
+
}
|
|
9464
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
9465
|
+
const status = libraryErrors.length > 0 ? "error" : "done";
|
|
9466
|
+
const errorMessage = libraryErrors.length > 0 ? libraryErrors.join("; ") : null;
|
|
9467
|
+
await database.update(scanJobs).set({
|
|
9468
|
+
added: totalAdded,
|
|
9469
|
+
endedAt,
|
|
9470
|
+
errorMessage,
|
|
9471
|
+
status,
|
|
9472
|
+
total: totalFound,
|
|
9473
|
+
updated: totalUpdated
|
|
9474
|
+
}).where(eq(scanJobs.id, inserted.id));
|
|
9475
|
+
const payload = {
|
|
9476
|
+
added: totalAdded,
|
|
9477
|
+
errorMessage,
|
|
9478
|
+
jobId: inserted.id,
|
|
9479
|
+
status,
|
|
9480
|
+
total: totalFound,
|
|
9481
|
+
updated: totalUpdated
|
|
9482
|
+
};
|
|
9483
|
+
emitter.emit("job-completed", payload);
|
|
9484
|
+
return {
|
|
9485
|
+
added: totalAdded,
|
|
9486
|
+
errorMessage: errorMessage ?? void 0,
|
|
9487
|
+
status,
|
|
9488
|
+
total: totalFound,
|
|
9489
|
+
updated: totalUpdated
|
|
9490
|
+
};
|
|
9491
|
+
} catch (caughtError) {
|
|
9492
|
+
const endedAt = /* @__PURE__ */ new Date();
|
|
9493
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9494
|
+
await database.update(scanJobs).set({
|
|
9495
|
+
added: totalAdded,
|
|
9496
|
+
endedAt,
|
|
9497
|
+
errorMessage: message,
|
|
9498
|
+
status: "error",
|
|
9499
|
+
total: totalFound,
|
|
9500
|
+
updated: totalUpdated
|
|
9501
|
+
}).where(eq(scanJobs.id, inserted.id));
|
|
9502
|
+
emitter.emit("job-failed", {
|
|
9503
|
+
error: message,
|
|
9504
|
+
jobId: inserted.id
|
|
9505
|
+
});
|
|
9506
|
+
return {
|
|
9507
|
+
added: totalAdded,
|
|
9508
|
+
errorMessage: message,
|
|
9509
|
+
status: "error",
|
|
9510
|
+
total: totalFound,
|
|
9511
|
+
updated: totalUpdated
|
|
9512
|
+
};
|
|
9513
|
+
} finally {
|
|
9514
|
+
currentJob = null;
|
|
9515
|
+
}
|
|
9516
|
+
}
|
|
9517
|
+
return {
|
|
9518
|
+
getStatus,
|
|
9519
|
+
isRunning,
|
|
9520
|
+
off: (event, listener) => emitter.off(event, listener),
|
|
9521
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
9522
|
+
recoverInterruptedJobs,
|
|
9523
|
+
start
|
|
9524
|
+
};
|
|
9525
|
+
}
|
|
9526
|
+
const scanManager = createScanManager();
|
|
9527
|
+
//#endregion
|
|
8504
9528
|
//#region src/api/routes/scan.ts
|
|
8505
9529
|
const scanRoutes = new Hono();
|
|
8506
9530
|
scanRoutes.get("/libraries", async (context) => {
|
|
@@ -8512,74 +9536,563 @@ scanRoutes.get("/libraries", async (context) => {
|
|
|
8512
9536
|
type: library.type
|
|
8513
9537
|
})));
|
|
8514
9538
|
});
|
|
8515
|
-
scanRoutes.get("/
|
|
8516
|
-
const
|
|
8517
|
-
|
|
9539
|
+
scanRoutes.get("/status", async (context) => {
|
|
9540
|
+
const status = await scanManager.getStatus();
|
|
9541
|
+
return context.json({
|
|
9542
|
+
currentJob: status.currentJob ? {
|
|
9543
|
+
added: status.currentJob.added,
|
|
9544
|
+
endedAt: status.currentJob.endedAt?.toISOString() ?? null,
|
|
9545
|
+
errorMessage: status.currentJob.errorMessage,
|
|
9546
|
+
id: status.currentJob.id,
|
|
9547
|
+
startedAt: status.currentJob.startedAt.toISOString(),
|
|
9548
|
+
status: status.currentJob.status,
|
|
9549
|
+
total: status.currentJob.total,
|
|
9550
|
+
updated: status.currentJob.updated
|
|
9551
|
+
} : null,
|
|
9552
|
+
isRunning: status.isRunning,
|
|
9553
|
+
lastJob: status.lastJob ? {
|
|
9554
|
+
added: status.lastJob.added,
|
|
9555
|
+
endedAt: status.lastJob.endedAt?.toISOString() ?? null,
|
|
9556
|
+
errorMessage: status.lastJob.errorMessage,
|
|
9557
|
+
id: status.lastJob.id,
|
|
9558
|
+
startedAt: status.lastJob.startedAt.toISOString(),
|
|
9559
|
+
status: status.lastJob.status,
|
|
9560
|
+
total: status.lastJob.total,
|
|
9561
|
+
updated: status.lastJob.updated
|
|
9562
|
+
} : null
|
|
9563
|
+
});
|
|
9564
|
+
});
|
|
9565
|
+
scanRoutes.post("/start", async (context) => {
|
|
9566
|
+
if ((await db.select().from(libraries)).length === 0) return context.json({ error: { message: "No libraries configured. Add a library in Settings before scanning." } }, 400);
|
|
9567
|
+
if (scanManager.isRunning()) return context.json({ status: "already-running" });
|
|
9568
|
+
scanManager.start().catch((caughtError) => {
|
|
9569
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9570
|
+
console.error(`Manual scan failed: ${message}`);
|
|
9571
|
+
});
|
|
9572
|
+
return context.json({ status: "started" });
|
|
9573
|
+
});
|
|
9574
|
+
scanRoutes.get("/stream", (context) => {
|
|
8518
9575
|
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({
|
|
9576
|
+
const onLibraryStart = (event) => {
|
|
9577
|
+
stream.writeSSE({
|
|
8526
9578
|
event: "library-start",
|
|
8527
|
-
data: JSON.stringify(
|
|
9579
|
+
data: JSON.stringify(event)
|
|
8528
9580
|
});
|
|
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
|
-
|
|
9581
|
+
};
|
|
9582
|
+
const onFileScanned = (event) => {
|
|
9583
|
+
stream.writeSSE({
|
|
9584
|
+
event: "file-scanned",
|
|
9585
|
+
data: JSON.stringify(event)
|
|
9586
|
+
});
|
|
9587
|
+
};
|
|
9588
|
+
const onLibraryComplete = (event) => {
|
|
9589
|
+
stream.writeSSE({
|
|
9590
|
+
event: "library-complete",
|
|
9591
|
+
data: JSON.stringify(event)
|
|
9592
|
+
});
|
|
9593
|
+
};
|
|
9594
|
+
const onLibraryError = (event) => {
|
|
9595
|
+
stream.writeSSE({
|
|
9596
|
+
event: "library-error",
|
|
9597
|
+
data: JSON.stringify(event)
|
|
9598
|
+
});
|
|
9599
|
+
};
|
|
9600
|
+
const onScanComplete = (event) => {
|
|
9601
|
+
stream.writeSSE({
|
|
9602
|
+
event: "scan-complete",
|
|
9603
|
+
data: JSON.stringify({
|
|
9604
|
+
added: event.added,
|
|
9605
|
+
errorMessage: event.errorMessage,
|
|
9606
|
+
found: event.total,
|
|
9607
|
+
status: event.status,
|
|
9608
|
+
updated: event.updated
|
|
9609
|
+
})
|
|
9610
|
+
});
|
|
9611
|
+
};
|
|
9612
|
+
scanManager.on("library-start", onLibraryStart);
|
|
9613
|
+
scanManager.on("file-scanned", onFileScanned);
|
|
9614
|
+
scanManager.on("library-complete", onLibraryComplete);
|
|
9615
|
+
scanManager.on("library-error", onLibraryError);
|
|
9616
|
+
scanManager.on("job-completed", onScanComplete);
|
|
9617
|
+
await stream.writeSSE({
|
|
9618
|
+
event: "ready",
|
|
9619
|
+
data: JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString() })
|
|
9620
|
+
});
|
|
9621
|
+
stream.onAbort(() => {
|
|
9622
|
+
scanManager.off("library-start", onLibraryStart);
|
|
9623
|
+
scanManager.off("file-scanned", onFileScanned);
|
|
9624
|
+
scanManager.off("library-complete", onLibraryComplete);
|
|
9625
|
+
scanManager.off("library-error", onLibraryError);
|
|
9626
|
+
scanManager.off("job-completed", onScanComplete);
|
|
9627
|
+
});
|
|
9628
|
+
const keepAliveMs = 15e3;
|
|
9629
|
+
try {
|
|
9630
|
+
while (!stream.aborted && !stream.closed) {
|
|
9631
|
+
await stream.sleep(keepAliveMs);
|
|
9632
|
+
if (stream.aborted || stream.closed) break;
|
|
8555
9633
|
await stream.writeSSE({
|
|
8556
|
-
event: "
|
|
8557
|
-
data:
|
|
8558
|
-
error: error instanceof Error ? error.message : "Unknown error",
|
|
8559
|
-
index: i
|
|
8560
|
-
})
|
|
9634
|
+
event: "ping",
|
|
9635
|
+
data: "{}"
|
|
8561
9636
|
});
|
|
8562
9637
|
}
|
|
9638
|
+
} catch {}
|
|
9639
|
+
});
|
|
9640
|
+
});
|
|
9641
|
+
//#endregion
|
|
9642
|
+
//#region src/services/fts-query-parser.ts
|
|
9643
|
+
/**
|
|
9644
|
+
* Convert raw user input into a safe SQLite FTS5 MATCH expression.
|
|
9645
|
+
*
|
|
9646
|
+
* FTS5 has a query syntax with operators (AND, OR, NEAR, NOT), prefix
|
|
9647
|
+
* wildcards (*), column filters (title:foo), and quoted phrases. We don't
|
|
9648
|
+
* want users to accidentally trigger any of that — and we don't want
|
|
9649
|
+
* adversarial input to crash the parser. So we:
|
|
9650
|
+
*
|
|
9651
|
+
* 1. Split the input on whitespace.
|
|
9652
|
+
* 2. Strip every character except letters, digits, and the underscore
|
|
9653
|
+
* from each token. This drops quotes, asterisks, parens, colons,
|
|
9654
|
+
* and operator-like punctuation.
|
|
9655
|
+
* 3. Wrap each surviving token in double quotes (so reserved words like
|
|
9656
|
+
* `OR` or `NEAR` are treated as literal terms) and append `*` for
|
|
9657
|
+
* prefix matching.
|
|
9658
|
+
* 4. Join the tokens with spaces. FTS5 implicitly ANDs them.
|
|
9659
|
+
*
|
|
9660
|
+
* Returns null when there is nothing searchable (empty input, only
|
|
9661
|
+
* whitespace, or only stripped characters).
|
|
9662
|
+
*/
|
|
9663
|
+
function buildFtsMatchQuery(rawInput) {
|
|
9664
|
+
const tokens = rawInput.split(/\s+/).map((token) => sanitizeToken(token)).filter((token) => token.length > 0);
|
|
9665
|
+
if (tokens.length === 0) return null;
|
|
9666
|
+
return tokens.map((token) => `"${token}"*`).join(" ");
|
|
9667
|
+
}
|
|
9668
|
+
function sanitizeToken(token) {
|
|
9669
|
+
return token.replace(/[^\p{L}\p{N}_]+/gu, "");
|
|
9670
|
+
}
|
|
9671
|
+
//#endregion
|
|
9672
|
+
//#region src/api/routes/search.ts
|
|
9673
|
+
const defaultLimit = 20;
|
|
9674
|
+
const maxLimit = 50;
|
|
9675
|
+
const maxQueryLength = 256;
|
|
9676
|
+
const searchRoutes = new Hono();
|
|
9677
|
+
searchRoutes.get("/", (context) => {
|
|
9678
|
+
const queryParameter = context.req.query("q");
|
|
9679
|
+
if (queryParameter === void 0) return context.json({ error: { message: "Add a `q` query parameter to search. Example: /api/search?q=dune" } }, 400);
|
|
9680
|
+
if (queryParameter.length > maxQueryLength) return context.json({ error: { message: "Search query is too long. Shorten it to under 256 characters." } }, 400);
|
|
9681
|
+
const limit = parseLimit(context.req.query("limit"));
|
|
9682
|
+
if (limit === null) return context.json({ error: { message: "`limit` must be a positive integer between 1 and 50." } }, 400);
|
|
9683
|
+
const matchExpression = buildFtsMatchQuery(queryParameter);
|
|
9684
|
+
if (matchExpression === null) return context.json({
|
|
9685
|
+
episodes: [],
|
|
9686
|
+
indexEmpty: false,
|
|
9687
|
+
movies: [],
|
|
9688
|
+
shows: []
|
|
9689
|
+
});
|
|
9690
|
+
try {
|
|
9691
|
+
const movies = searchMovies(matchExpression, limit);
|
|
9692
|
+
const shows = searchShows(matchExpression, limit);
|
|
9693
|
+
const episodes = searchEpisodes(matchExpression, limit);
|
|
9694
|
+
const response = {
|
|
9695
|
+
episodes,
|
|
9696
|
+
indexEmpty: movies.length === 0 && shows.length === 0 && episodes.length === 0 && isLibraryEmpty(),
|
|
9697
|
+
movies,
|
|
9698
|
+
shows
|
|
9699
|
+
};
|
|
9700
|
+
return context.json(response);
|
|
9701
|
+
} catch (error) {
|
|
9702
|
+
log.error("Search query failed.", error);
|
|
9703
|
+
return context.json({ error: { message: "Your search couldn't be parsed. Try simpler terms or fewer special characters." } }, 400);
|
|
9704
|
+
}
|
|
9705
|
+
});
|
|
9706
|
+
function isLibraryEmpty() {
|
|
9707
|
+
const [movieCountRow] = db.all(sql`
|
|
9708
|
+
SELECT COUNT(*) AS count FROM movies
|
|
9709
|
+
`);
|
|
9710
|
+
const [showCountRow] = db.all(sql`
|
|
9711
|
+
SELECT COUNT(*) AS count FROM shows
|
|
9712
|
+
`);
|
|
9713
|
+
const [episodeCountRow] = db.all(sql`
|
|
9714
|
+
SELECT COUNT(*) AS count FROM episodes
|
|
9715
|
+
`);
|
|
9716
|
+
return (movieCountRow?.count ?? 0) + (showCountRow?.count ?? 0) + (episodeCountRow?.count ?? 0) === 0;
|
|
9717
|
+
}
|
|
9718
|
+
function parseLimit(raw) {
|
|
9719
|
+
if (raw === void 0 || raw.trim() === "") return defaultLimit;
|
|
9720
|
+
const parsed = Number(raw);
|
|
9721
|
+
if (!Number.isInteger(parsed) || parsed < 1) return null;
|
|
9722
|
+
return Math.min(parsed, maxLimit);
|
|
9723
|
+
}
|
|
9724
|
+
function searchMovies(matchExpression, limit) {
|
|
9725
|
+
return db.all(sql`
|
|
9726
|
+
SELECT
|
|
9727
|
+
movies.id AS id,
|
|
9728
|
+
movies.title AS title,
|
|
9729
|
+
movies.year AS year,
|
|
9730
|
+
movies.overview AS overview,
|
|
9731
|
+
movies.poster_path AS poster_path,
|
|
9732
|
+
movies.backdrop_path AS backdrop_path
|
|
9733
|
+
FROM movies_fts
|
|
9734
|
+
JOIN movies ON movies.rowid = movies_fts.rowid
|
|
9735
|
+
WHERE movies_fts MATCH ${matchExpression}
|
|
9736
|
+
ORDER BY bm25(movies_fts) ASC
|
|
9737
|
+
LIMIT ${limit}
|
|
9738
|
+
`).map((row) => ({
|
|
9739
|
+
backdropPath: row.backdrop_path,
|
|
9740
|
+
id: row.id,
|
|
9741
|
+
overview: row.overview,
|
|
9742
|
+
posterPath: row.poster_path,
|
|
9743
|
+
title: row.title,
|
|
9744
|
+
year: row.year
|
|
9745
|
+
}));
|
|
9746
|
+
}
|
|
9747
|
+
function searchShows(matchExpression, limit) {
|
|
9748
|
+
return db.all(sql`
|
|
9749
|
+
SELECT
|
|
9750
|
+
shows.id AS id,
|
|
9751
|
+
shows.title AS title,
|
|
9752
|
+
shows.year AS year,
|
|
9753
|
+
shows.overview AS overview,
|
|
9754
|
+
shows.poster_path AS poster_path,
|
|
9755
|
+
shows.backdrop_path AS backdrop_path
|
|
9756
|
+
FROM shows_fts
|
|
9757
|
+
JOIN shows ON shows.rowid = shows_fts.rowid
|
|
9758
|
+
WHERE shows_fts MATCH ${matchExpression}
|
|
9759
|
+
ORDER BY bm25(shows_fts) ASC
|
|
9760
|
+
LIMIT ${limit}
|
|
9761
|
+
`).map((row) => ({
|
|
9762
|
+
backdropPath: row.backdrop_path,
|
|
9763
|
+
id: row.id,
|
|
9764
|
+
overview: row.overview,
|
|
9765
|
+
posterPath: row.poster_path,
|
|
9766
|
+
title: row.title,
|
|
9767
|
+
year: row.year
|
|
9768
|
+
}));
|
|
9769
|
+
}
|
|
9770
|
+
function searchEpisodes(matchExpression, limit) {
|
|
9771
|
+
return db.all(sql`
|
|
9772
|
+
SELECT
|
|
9773
|
+
episodes.id AS id,
|
|
9774
|
+
episodes.show_id AS show_id,
|
|
9775
|
+
episodes.season_number AS season_number,
|
|
9776
|
+
episodes.episode_number AS episode_number,
|
|
9777
|
+
episodes.title AS title,
|
|
9778
|
+
episodes.overview AS overview,
|
|
9779
|
+
episodes.still_path AS still_path,
|
|
9780
|
+
shows.title AS show_title
|
|
9781
|
+
FROM episodes_fts
|
|
9782
|
+
JOIN episodes ON episodes.rowid = episodes_fts.rowid
|
|
9783
|
+
LEFT JOIN shows ON shows.id = episodes.show_id
|
|
9784
|
+
WHERE episodes_fts MATCH ${matchExpression}
|
|
9785
|
+
ORDER BY bm25(episodes_fts) ASC
|
|
9786
|
+
LIMIT ${limit}
|
|
9787
|
+
`).map((row) => ({
|
|
9788
|
+
episodeNumber: row.episode_number,
|
|
9789
|
+
id: row.id,
|
|
9790
|
+
overview: row.overview,
|
|
9791
|
+
seasonNumber: row.season_number,
|
|
9792
|
+
showId: row.show_id,
|
|
9793
|
+
showTitle: row.show_title ?? "",
|
|
9794
|
+
stillPath: row.still_path,
|
|
9795
|
+
title: row.title
|
|
9796
|
+
}));
|
|
9797
|
+
}
|
|
9798
|
+
//#endregion
|
|
9799
|
+
//#region src/services/scheduler.ts
|
|
9800
|
+
const millisecondsPerHour = 3600 * 1e3;
|
|
9801
|
+
function scheduleIntervalMilliseconds(value) {
|
|
9802
|
+
switch (value) {
|
|
9803
|
+
case "off": return null;
|
|
9804
|
+
case "6h": return 6 * millisecondsPerHour;
|
|
9805
|
+
case "12h": return 12 * millisecondsPerHour;
|
|
9806
|
+
case "24h": return 24 * millisecondsPerHour;
|
|
9807
|
+
}
|
|
9808
|
+
}
|
|
9809
|
+
async function defaultGetSchedule() {
|
|
9810
|
+
const stored = await getSetting(scanScheduleSettingKey);
|
|
9811
|
+
if (stored === null) return "off";
|
|
9812
|
+
if (isScanScheduleValue(stored)) return stored;
|
|
9813
|
+
log.warn(`Unknown scanSchedule value "${stored}" in settings. Falling back to "off".`);
|
|
9814
|
+
return "off";
|
|
9815
|
+
}
|
|
9816
|
+
function createScheduler(dependencies = {}) {
|
|
9817
|
+
const getSchedule = dependencies.getSchedule ?? defaultGetSchedule;
|
|
9818
|
+
const scanManager$1 = dependencies.scanManager ?? {
|
|
9819
|
+
isRunning: () => scanManager.isRunning(),
|
|
9820
|
+
start: () => scanManager.start()
|
|
9821
|
+
};
|
|
9822
|
+
let currentSchedule = "off";
|
|
9823
|
+
let nextRunAt = null;
|
|
9824
|
+
let timer = null;
|
|
9825
|
+
function clearTimer() {
|
|
9826
|
+
if (timer !== null) {
|
|
9827
|
+
clearTimeout(timer);
|
|
9828
|
+
timer = null;
|
|
9829
|
+
}
|
|
9830
|
+
}
|
|
9831
|
+
function scheduleNext(intervalMs) {
|
|
9832
|
+
clearTimer();
|
|
9833
|
+
nextRunAt = new Date(Date.now() + intervalMs);
|
|
9834
|
+
timer = setTimeout(() => {
|
|
9835
|
+
tick();
|
|
9836
|
+
}, intervalMs);
|
|
9837
|
+
}
|
|
9838
|
+
async function tick() {
|
|
9839
|
+
if (scanManager$1.isRunning()) log.debug("Scheduler tick skipped — a scan is already running.");
|
|
9840
|
+
else try {
|
|
9841
|
+
const result = await scanManager$1.start();
|
|
9842
|
+
if (result.status === "error") {
|
|
9843
|
+
const message = "errorMessage" in result && result.errorMessage ? `: ${result.errorMessage}` : ".";
|
|
9844
|
+
log.warn(`Scheduled scan finished with errors${message}`);
|
|
9845
|
+
} else if (result.status === "no-libraries") log.debug("Scheduled scan skipped — no libraries configured.");
|
|
9846
|
+
} catch (caughtError) {
|
|
9847
|
+
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
|
|
9848
|
+
log.warn(`Scheduled scan failed to start: ${message}. Check the scan page.`);
|
|
9849
|
+
}
|
|
9850
|
+
const activeInterval = scheduleIntervalMilliseconds(currentSchedule);
|
|
9851
|
+
if (activeInterval === null) {
|
|
9852
|
+
nextRunAt = null;
|
|
9853
|
+
return;
|
|
9854
|
+
}
|
|
9855
|
+
scheduleNext(activeInterval);
|
|
9856
|
+
}
|
|
9857
|
+
function applySchedule(value) {
|
|
9858
|
+
currentSchedule = value;
|
|
9859
|
+
const intervalMs = scheduleIntervalMilliseconds(value);
|
|
9860
|
+
if (intervalMs === null) {
|
|
9861
|
+
clearTimer();
|
|
9862
|
+
nextRunAt = null;
|
|
9863
|
+
log.info("Scan scheduler is off.");
|
|
9864
|
+
return;
|
|
9865
|
+
}
|
|
9866
|
+
scheduleNext(intervalMs);
|
|
9867
|
+
log.info(`Next scheduled scan at ${nextRunAt?.toISOString() ?? "unknown"} (${value}).`);
|
|
9868
|
+
}
|
|
9869
|
+
return {
|
|
9870
|
+
getInfo: () => ({
|
|
9871
|
+
nextRunAt,
|
|
9872
|
+
schedule: currentSchedule
|
|
9873
|
+
}),
|
|
9874
|
+
start: async () => {
|
|
9875
|
+
applySchedule(await getSchedule());
|
|
9876
|
+
},
|
|
9877
|
+
stop: () => {
|
|
9878
|
+
clearTimer();
|
|
9879
|
+
currentSchedule = "off";
|
|
9880
|
+
nextRunAt = null;
|
|
9881
|
+
},
|
|
9882
|
+
updateSchedule: (value) => {
|
|
9883
|
+
applySchedule(value);
|
|
9884
|
+
}
|
|
9885
|
+
};
|
|
9886
|
+
}
|
|
9887
|
+
const scheduler = createScheduler();
|
|
9888
|
+
//#endregion
|
|
9889
|
+
//#region src/api/routes/settings.ts
|
|
9890
|
+
const tmdbConfigurationUrl = "https://api.themoviedb.org/3/configuration";
|
|
9891
|
+
const reservedKeys = new Set([tmdbApiKeySettingKey, scanScheduleSettingKey]);
|
|
9892
|
+
const reservedKeyRoute = {
|
|
9893
|
+
[tmdbApiKeySettingKey]: "/api/settings/tmdb-key",
|
|
9894
|
+
[scanScheduleSettingKey]: "/api/settings/scan-schedule"
|
|
9895
|
+
};
|
|
9896
|
+
async function verifyTmdbKey(apiKey) {
|
|
9897
|
+
try {
|
|
9898
|
+
return (await fetch(`${tmdbConfigurationUrl}?api_key=${encodeURIComponent(apiKey)}`)).ok;
|
|
9899
|
+
} catch {
|
|
9900
|
+
return false;
|
|
9901
|
+
}
|
|
9902
|
+
}
|
|
9903
|
+
async function pathIsReadable(path) {
|
|
9904
|
+
try {
|
|
9905
|
+
await access(path, constants.R_OK);
|
|
9906
|
+
return true;
|
|
9907
|
+
} catch {
|
|
9908
|
+
return false;
|
|
9909
|
+
}
|
|
9910
|
+
}
|
|
9911
|
+
function validateLibraryInput(input) {
|
|
9912
|
+
if (typeof input !== "object" || input === null) return "Library entry must be an object.";
|
|
9913
|
+
const record = input;
|
|
9914
|
+
const name = typeof record.name === "string" ? record.name.trim() : "";
|
|
9915
|
+
const libraryPath = typeof record.path === "string" ? record.path.trim() : "";
|
|
9916
|
+
const type = record.type;
|
|
9917
|
+
if (libraryPath.length === 0) return "Library path is required.";
|
|
9918
|
+
if (type !== "movies" && type !== "shows") return "Library type must be \"movies\" or \"shows\".";
|
|
9919
|
+
return {
|
|
9920
|
+
name,
|
|
9921
|
+
path: libraryPath,
|
|
9922
|
+
type
|
|
9923
|
+
};
|
|
9924
|
+
}
|
|
9925
|
+
/**
|
|
9926
|
+
* Build the LIKE pattern that matches any file path under the given library
|
|
9927
|
+
* root. Escapes SQL LIKE wildcards so a path containing `%` or `_` doesn't
|
|
9928
|
+
* accidentally match siblings.
|
|
9929
|
+
*/
|
|
9930
|
+
function libraryPathPrefixPattern(libraryPath) {
|
|
9931
|
+
const resolved = path.resolve(libraryPath);
|
|
9932
|
+
return `${(resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`).replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`;
|
|
9933
|
+
}
|
|
9934
|
+
const settingsRoutes = new Hono();
|
|
9935
|
+
settingsRoutes.get("/tmdb-key", async (context) => {
|
|
9936
|
+
const apiKey = await getTmdbApiKey();
|
|
9937
|
+
return context.json({
|
|
9938
|
+
configured: apiKey !== null && apiKey.length > 0,
|
|
9939
|
+
apiKey: apiKey ?? ""
|
|
9940
|
+
});
|
|
9941
|
+
});
|
|
9942
|
+
settingsRoutes.put("/tmdb-key", async (context) => {
|
|
9943
|
+
let body;
|
|
9944
|
+
try {
|
|
9945
|
+
body = await context.req.json();
|
|
9946
|
+
} catch {
|
|
9947
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
9948
|
+
}
|
|
9949
|
+
const apiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
|
|
9950
|
+
if (apiKey.length === 0) return context.json({ error: { message: "TMDB API key is required. Get one at themoviedb.org/settings/api." } }, 400);
|
|
9951
|
+
if (!await verifyTmdbKey(apiKey)) return context.json({ error: { message: "Couldn't save TMDB key — the key wasn't accepted by TMDB. Check it at themoviedb.org/settings/api." } }, 400);
|
|
9952
|
+
await setTmdbApiKey(apiKey);
|
|
9953
|
+
return context.json({ configured: true });
|
|
9954
|
+
});
|
|
9955
|
+
settingsRoutes.get("/libraries", async (context) => {
|
|
9956
|
+
const libraries$2 = await db.select().from(libraries);
|
|
9957
|
+
return context.json(libraries$2.map((library) => ({
|
|
9958
|
+
id: library.id,
|
|
9959
|
+
name: library.name,
|
|
9960
|
+
path: library.path,
|
|
9961
|
+
type: library.type
|
|
9962
|
+
})));
|
|
9963
|
+
});
|
|
9964
|
+
settingsRoutes.post("/libraries", async (context) => {
|
|
9965
|
+
let body;
|
|
9966
|
+
try {
|
|
9967
|
+
body = await context.req.json();
|
|
9968
|
+
} catch {
|
|
9969
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
9970
|
+
}
|
|
9971
|
+
const parsed = validateLibraryInput(body);
|
|
9972
|
+
if (typeof parsed === "string") return context.json({ error: { message: parsed } }, 400);
|
|
9973
|
+
if (!await pathIsReadable(parsed.path)) return context.json({ error: { message: `Library path doesn't exist or isn't readable: ${parsed.path}. Check the path and Jukebox's file permissions.` } }, 400);
|
|
9974
|
+
const [existing] = await db.select().from(libraries).where(eq(libraries.path, parsed.path)).limit(1);
|
|
9975
|
+
if (existing) return context.json({ error: { message: `A library at ${parsed.path} already exists.` } }, 400);
|
|
9976
|
+
const resolvedName = parsed.name.length > 0 ? parsed.name : parsed.path.split(/[\\/]/).filter(Boolean).pop() ?? parsed.type;
|
|
9977
|
+
const [created] = await db.insert(libraries).values({
|
|
9978
|
+
name: resolvedName,
|
|
9979
|
+
path: parsed.path,
|
|
9980
|
+
type: parsed.type,
|
|
9981
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
9982
|
+
}).returning();
|
|
9983
|
+
if (!created) return context.json({ error: { message: "Failed to create library." } }, 500);
|
|
9984
|
+
return context.json({
|
|
9985
|
+
id: created.id,
|
|
9986
|
+
name: created.name,
|
|
9987
|
+
path: created.path,
|
|
9988
|
+
type: created.type
|
|
9989
|
+
}, 201);
|
|
9990
|
+
});
|
|
9991
|
+
settingsRoutes.delete("/libraries/:id", async (context) => {
|
|
9992
|
+
const id = Number.parseInt(context.req.param("id"), 10);
|
|
9993
|
+
if (!Number.isFinite(id)) return context.json({ error: { message: "Invalid library id." } }, 400);
|
|
9994
|
+
const [existing] = await db.select().from(libraries).where(eq(libraries.id, id)).limit(1);
|
|
9995
|
+
if (!existing) return context.json({ error: { message: "Library not found." } }, 404);
|
|
9996
|
+
const force = new URL(context.req.url).searchParams.get("force") === "true";
|
|
9997
|
+
const pattern = libraryPathPrefixPattern(existing.path);
|
|
9998
|
+
if (!force) {
|
|
9999
|
+
const referenceCount = countLibraryReferences(existing, pattern);
|
|
10000
|
+
if (referenceCount > 0) {
|
|
10001
|
+
const noun = existing.type === "movies" ? referenceCount === 1 ? "movie" : "movies" : referenceCount === 1 ? "show" : "shows";
|
|
10002
|
+
return context.json({ error: {
|
|
10003
|
+
message: `Couldn't remove library — ${referenceCount} ${noun} reference it. Remove them first or use 'Force remove'.`,
|
|
10004
|
+
referenceCount
|
|
10005
|
+
} }, 409);
|
|
10006
|
+
}
|
|
10007
|
+
db.delete(libraries).where(eq(libraries.id, id)).run();
|
|
10008
|
+
return context.json({ success: true });
|
|
10009
|
+
}
|
|
10010
|
+
db.transaction((tx) => {
|
|
10011
|
+
if (existing.type === "movies") {
|
|
10012
|
+
const movieIds = tx.select({ id: movies.id }).from(movies).where(sql`${movies.filePath} LIKE ${pattern} ESCAPE '\\'`).all().map((row) => row.id);
|
|
10013
|
+
if (movieIds.length > 0) {
|
|
10014
|
+
tx.delete(watchProgress).where(inArray(watchProgress.movieId, movieIds)).run();
|
|
10015
|
+
tx.delete(movies).where(inArray(movies.id, movieIds)).run();
|
|
10016
|
+
}
|
|
10017
|
+
} else {
|
|
10018
|
+
const showIds = tx.select({ id: shows.id }).from(shows).where(sql`${shows.folderPath} LIKE ${pattern} ESCAPE '\\'`).all().map((row) => row.id);
|
|
10019
|
+
if (showIds.length > 0) {
|
|
10020
|
+
const episodeIds = tx.select({ id: episodes.id }).from(episodes).where(inArray(episodes.showId, showIds)).all().map((row) => row.id);
|
|
10021
|
+
if (episodeIds.length > 0) {
|
|
10022
|
+
tx.delete(watchProgress).where(inArray(watchProgress.episodeId, episodeIds)).run();
|
|
10023
|
+
tx.delete(episodes).where(inArray(episodes.id, episodeIds)).run();
|
|
10024
|
+
}
|
|
10025
|
+
tx.delete(seasons).where(inArray(seasons.showId, showIds)).run();
|
|
10026
|
+
tx.delete(shows).where(inArray(shows.id, showIds)).run();
|
|
10027
|
+
}
|
|
8563
10028
|
}
|
|
8564
|
-
|
|
8565
|
-
|
|
8566
|
-
|
|
8567
|
-
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
8571
|
-
|
|
10029
|
+
tx.delete(libraries).where(eq(libraries.id, id)).run();
|
|
10030
|
+
});
|
|
10031
|
+
return context.json({ success: true });
|
|
10032
|
+
});
|
|
10033
|
+
function countLibraryReferences(library, pattern) {
|
|
10034
|
+
if (library.type === "movies") {
|
|
10035
|
+
const [row] = db.select({ count: sql`count(*)` }).from(movies).where(sql`${movies.filePath} LIKE ${pattern} ESCAPE '\\'`).all();
|
|
10036
|
+
return row?.count ?? 0;
|
|
10037
|
+
}
|
|
10038
|
+
const [row] = db.select({ count: sql`count(*)` }).from(shows).where(sql`${shows.folderPath} LIKE ${pattern} ESCAPE '\\'`).all();
|
|
10039
|
+
return row?.count ?? 0;
|
|
10040
|
+
}
|
|
10041
|
+
settingsRoutes.get("/scan-schedule", async (context) => {
|
|
10042
|
+
const stored = await getSetting(scanScheduleSettingKey);
|
|
10043
|
+
const value = stored !== null && isScanScheduleValue(stored) ? stored : "off";
|
|
10044
|
+
const info = scheduler.getInfo();
|
|
10045
|
+
return context.json({
|
|
10046
|
+
nextRunAt: info.nextRunAt?.toISOString() ?? null,
|
|
10047
|
+
schedule: value
|
|
10048
|
+
});
|
|
10049
|
+
});
|
|
10050
|
+
settingsRoutes.put("/scan-schedule", async (context) => {
|
|
10051
|
+
let body;
|
|
10052
|
+
try {
|
|
10053
|
+
body = await context.req.json();
|
|
10054
|
+
} catch {
|
|
10055
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
10056
|
+
}
|
|
10057
|
+
const schedule = typeof body.schedule === "string" ? body.schedule.trim() : "";
|
|
10058
|
+
if (!isScanScheduleValue(schedule)) return context.json({ error: { message: "Scan schedule must be one of: off, 6h, 12h, 24h." } }, 400);
|
|
10059
|
+
await setSetting(scanScheduleSettingKey, schedule);
|
|
10060
|
+
scheduler.updateSchedule(schedule);
|
|
10061
|
+
const info = scheduler.getInfo();
|
|
10062
|
+
return context.json({
|
|
10063
|
+
nextRunAt: info.nextRunAt?.toISOString() ?? null,
|
|
10064
|
+
schedule
|
|
8572
10065
|
});
|
|
8573
10066
|
});
|
|
10067
|
+
settingsRoutes.get("/:key", async (context) => {
|
|
10068
|
+
const key = context.req.param("key");
|
|
10069
|
+
if (reservedKeys.has(key)) return context.json({ error: { message: `Use GET ${reservedKeyRoute[key]} for this key.` } }, 400);
|
|
10070
|
+
const value = await getSetting(key);
|
|
10071
|
+
if (value === null) return context.json({ value: null });
|
|
10072
|
+
return context.json({ value });
|
|
10073
|
+
});
|
|
10074
|
+
settingsRoutes.put("/:key", async (context) => {
|
|
10075
|
+
const key = context.req.param("key");
|
|
10076
|
+
if (reservedKeys.has(key)) return context.json({ error: { message: `Use PUT ${reservedKeyRoute[key]} for this key.` } }, 400);
|
|
10077
|
+
let body;
|
|
10078
|
+
try {
|
|
10079
|
+
body = await context.req.json();
|
|
10080
|
+
} catch {
|
|
10081
|
+
return context.json({ error: { message: "Invalid request body." } }, 400);
|
|
10082
|
+
}
|
|
10083
|
+
if (typeof body.value !== "string") return context.json({ error: { message: "Setting value must be a string." } }, 400);
|
|
10084
|
+
await setSetting(key, body.value);
|
|
10085
|
+
return context.json({ value: body.value });
|
|
10086
|
+
});
|
|
8574
10087
|
//#endregion
|
|
8575
10088
|
//#region src/api/routes/setup.ts
|
|
8576
10089
|
const setupRoutes = new Hono();
|
|
8577
10090
|
setupRoutes.get("/", async (context) => {
|
|
8578
|
-
const
|
|
10091
|
+
const apiKey = await getTmdbApiKey();
|
|
8579
10092
|
const libraries$1 = await db.select().from(libraries);
|
|
8580
10093
|
return context.json({
|
|
8581
|
-
config:
|
|
8582
|
-
hasApiKey:
|
|
10094
|
+
config: apiKey !== null ? { tmdbApiKey: apiKey } : null,
|
|
10095
|
+
hasApiKey: apiKey !== null && apiKey !== "",
|
|
8583
10096
|
libraries: libraries$1.map((library) => ({
|
|
8584
10097
|
id: library.id,
|
|
8585
10098
|
name: library.name,
|
|
@@ -8594,7 +10107,7 @@ setupRoutes.post("/complete", async (context) => {
|
|
|
8594
10107
|
const body = await context.req.json();
|
|
8595
10108
|
if (!body.tmdbApiKey) return context.json({ error: { message: "TMDB API key is required" } }, 400);
|
|
8596
10109
|
if (!body.libraries || body.libraries.length === 0) return context.json({ error: { message: "At least one library is required" } }, 400);
|
|
8597
|
-
await
|
|
10110
|
+
await setTmdbApiKey(body.tmdbApiKey);
|
|
8598
10111
|
await db.delete(libraries);
|
|
8599
10112
|
const now = /* @__PURE__ */ new Date();
|
|
8600
10113
|
for (const library of body.libraries) await db.insert(libraries).values({
|
|
@@ -8746,17 +10259,6 @@ favoriteRoutes.delete("/", async (context) => {
|
|
|
8746
10259
|
return context.json({ error: { message: "movieId or showId required" } }, 400);
|
|
8747
10260
|
});
|
|
8748
10261
|
//#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
10262
|
//#region src/api/routes/shows.ts
|
|
8761
10263
|
const showRoutes = new Hono();
|
|
8762
10264
|
showRoutes.get("/", async (context) => {
|
|
@@ -8778,38 +10280,31 @@ showRoutes.get("/episodes/:id", async (context) => {
|
|
|
8778
10280
|
const [episode] = await db.select().from(episodes).where(eq(episodes.id, id)).limit(1);
|
|
8779
10281
|
if (!episode) return context.json({ error: { message: "Episode not found" } }, 404);
|
|
8780
10282
|
const [show] = await db.select().from(shows).where(eq(shows.id, episode.showId)).limit(1);
|
|
10283
|
+
const subtitles$1 = (await db.select().from(subtitles).where(eq(subtitles.episodeId, id))).map((row) => ({
|
|
10284
|
+
id: row.id,
|
|
10285
|
+
displayLanguage: languageDisplayName(row.language),
|
|
10286
|
+
format: row.format,
|
|
10287
|
+
isSupported: row.format !== "ass",
|
|
10288
|
+
language: row.language
|
|
10289
|
+
}));
|
|
8781
10290
|
return context.json({
|
|
8782
10291
|
episode,
|
|
8783
|
-
show
|
|
10292
|
+
show,
|
|
10293
|
+
subtitles: subtitles$1
|
|
8784
10294
|
});
|
|
8785
10295
|
});
|
|
8786
10296
|
showRoutes.get("/:showId/next-episode", async (context) => {
|
|
8787
|
-
const profileId = context.get("profileId");
|
|
8788
10297
|
const showId = parseInt(context.req.param("showId"), 10);
|
|
8789
10298
|
const afterEpisodeIdParam = context.req.query("afterEpisodeId");
|
|
8790
10299
|
const afterEpisodeId = afterEpisodeIdParam ? parseInt(afterEpisodeIdParam, 10) : NaN;
|
|
8791
10300
|
if (isNaN(showId) || isNaN(afterEpisodeId)) return context.json({ error: { message: "Provide a numeric showId and a numeric afterEpisodeId query parameter." } }, 400);
|
|
8792
10301
|
const [currentEpisode] = await db.select().from(episodes).where(and(eq(episodes.id, afterEpisodeId), eq(episodes.showId, showId))).limit(1);
|
|
8793
10302
|
if (!currentEpisode) return context.json({ error: { message: "That episode does not belong to this show. Double-check the showId and afterEpisodeId." } }, 404);
|
|
8794
|
-
const
|
|
10303
|
+
const nextEpisode = (await db.select().from(episodes).where(eq(episodes.showId, showId)).orderBy(episodes.seasonNumber, episodes.episodeNumber)).find((candidate) => {
|
|
8795
10304
|
if (candidate.seasonNumber > currentEpisode.seasonNumber) return true;
|
|
8796
10305
|
if (candidate.seasonNumber < currentEpisode.seasonNumber) return false;
|
|
8797
10306
|
return candidate.episodeNumber > currentEpisode.episodeNumber;
|
|
8798
10307
|
});
|
|
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
10308
|
if (!nextEpisode) return context.json({ error: { message: "No more episodes after this one." } }, 404);
|
|
8814
10309
|
const [show] = await db.select().from(shows).where(eq(shows.id, showId)).limit(1);
|
|
8815
10310
|
return context.json({
|
|
@@ -8893,6 +10388,60 @@ streamRoutes.get("/:id", async (context) => {
|
|
|
8893
10388
|
} });
|
|
8894
10389
|
});
|
|
8895
10390
|
//#endregion
|
|
10391
|
+
//#region src/api/routes/subtitles.ts
|
|
10392
|
+
const subtitleRoutes = new Hono();
|
|
10393
|
+
const conversionFailedMessage = "Couldn't convert subtitle file. Check it's a valid SRT.";
|
|
10394
|
+
const outsideLibraryMessage = "Subtitle is outside the configured library paths. Rescan your libraries.";
|
|
10395
|
+
function subtitleIsInsideALibrary(subtitleFilePath, libraryPaths) {
|
|
10396
|
+
const resolvedSubtitle = path.resolve(subtitleFilePath);
|
|
10397
|
+
for (const libraryPath of libraryPaths) {
|
|
10398
|
+
const resolvedLibrary = path.resolve(libraryPath);
|
|
10399
|
+
const libraryWithSeparator = resolvedLibrary.endsWith(path.sep) ? resolvedLibrary : `${resolvedLibrary}${path.sep}`;
|
|
10400
|
+
if (resolvedSubtitle.startsWith(libraryWithSeparator)) return true;
|
|
10401
|
+
}
|
|
10402
|
+
return false;
|
|
10403
|
+
}
|
|
10404
|
+
subtitleRoutes.get("/:id", async (context) => {
|
|
10405
|
+
const id = parseInt(context.req.param("id"), 10);
|
|
10406
|
+
if (isNaN(id)) return context.json({ error: { message: "Invalid subtitle ID" } }, 400);
|
|
10407
|
+
const [subtitle] = await db.select().from(subtitles).where(eq(subtitles.id, id)).limit(1);
|
|
10408
|
+
if (!subtitle) return context.json({ error: { message: "Subtitle not found" } }, 404);
|
|
10409
|
+
const libraryPaths = (await db.select({ path: libraries.path }).from(libraries)).map((library) => library.path);
|
|
10410
|
+
if (!subtitleIsInsideALibrary(subtitle.filePath, libraryPaths)) {
|
|
10411
|
+
log.warn(`Refusing to serve subtitle ${subtitle.id} — ${subtitle.filePath} is not inside any configured library. Rescan the library to clean up stale rows.`);
|
|
10412
|
+
return context.json({ error: { message: outsideLibraryMessage } }, 404);
|
|
10413
|
+
}
|
|
10414
|
+
if (subtitle.format === "ass") return context.text("ASS subtitles aren't supported by the web player. Convert the file to .srt or .vtt and rescan.", 415);
|
|
10415
|
+
let raw;
|
|
10416
|
+
try {
|
|
10417
|
+
raw = await readFile(subtitle.filePath, "utf-8");
|
|
10418
|
+
} catch (error) {
|
|
10419
|
+
log.warn(`Couldn't read subtitle file ${subtitle.filePath} - ${error instanceof Error ? error.message : String(error)}. Make sure the file still exists at that path and rescan the library.`);
|
|
10420
|
+
return context.text(conversionFailedMessage, 500);
|
|
10421
|
+
}
|
|
10422
|
+
if (subtitle.format === "vtt") return new Response(raw, {
|
|
10423
|
+
status: 200,
|
|
10424
|
+
headers: {
|
|
10425
|
+
"Cache-Control": "public, max-age=3600",
|
|
10426
|
+
"Content-Type": "text/vtt; charset=utf-8"
|
|
10427
|
+
}
|
|
10428
|
+
});
|
|
10429
|
+
let converted;
|
|
10430
|
+
try {
|
|
10431
|
+
converted = convertSrtToVtt(raw);
|
|
10432
|
+
} catch (error) {
|
|
10433
|
+
log.warn(`Couldn't convert subtitle file ${subtitle.filePath} to WebVTT - ${error instanceof Error ? error.message : String(error)}. Convert the file to UTF-8 .vtt and rescan.`);
|
|
10434
|
+
return context.text(conversionFailedMessage, 500);
|
|
10435
|
+
}
|
|
10436
|
+
return new Response(converted, {
|
|
10437
|
+
status: 200,
|
|
10438
|
+
headers: {
|
|
10439
|
+
"Cache-Control": "public, max-age=3600",
|
|
10440
|
+
"Content-Type": "text/vtt; charset=utf-8"
|
|
10441
|
+
}
|
|
10442
|
+
});
|
|
10443
|
+
});
|
|
10444
|
+
//#endregion
|
|
8896
10445
|
//#region src/api/routes/transcode.ts
|
|
8897
10446
|
const logger = {
|
|
8898
10447
|
error: (...args) => console.error("[transcode]", ...args),
|
|
@@ -9089,6 +10638,17 @@ try {
|
|
|
9089
10638
|
logger.warn("Failed to clear transcode root on boot:", error);
|
|
9090
10639
|
}
|
|
9091
10640
|
//#endregion
|
|
10641
|
+
//#region src/lib/watched.ts
|
|
10642
|
+
/**
|
|
10643
|
+
* Shared "watched" threshold used across the server and client.
|
|
10644
|
+
*
|
|
10645
|
+
* An episode (or movie) is considered complete once the profile has
|
|
10646
|
+
* progressed at least this fraction of the total duration. Kept in a single
|
|
10647
|
+
* module so server endpoints ("Up Next", next-episode) and client overlays
|
|
10648
|
+
* ("Up Next" countdown) can't drift.
|
|
10649
|
+
*/
|
|
10650
|
+
const watchedThreshold = .9;
|
|
10651
|
+
//#endregion
|
|
9092
10652
|
//#region src/api/routes/up-next.ts
|
|
9093
10653
|
const upNextRoutes = new Hono();
|
|
9094
10654
|
upNextRoutes.get("/", async (context) => {
|
|
@@ -9154,11 +10714,14 @@ app.route("/api/library", libraryRoutes);
|
|
|
9154
10714
|
app.route("/api/progress/episode", episodeProgressRoutes);
|
|
9155
10715
|
app.route("/api/progress", progressRoutes);
|
|
9156
10716
|
app.route("/api/scan", scanRoutes);
|
|
10717
|
+
app.route("/api/search", searchRoutes);
|
|
9157
10718
|
app.route("/api/profiles", profileRoutes);
|
|
9158
10719
|
app.route("/api/favorites", favoriteRoutes);
|
|
10720
|
+
app.route("/api/settings", settingsRoutes);
|
|
9159
10721
|
app.route("/api/setup", setupRoutes);
|
|
9160
10722
|
app.route("/api/stream/episode", episodeStreamRoutes);
|
|
9161
10723
|
app.route("/api/stream", streamRoutes);
|
|
10724
|
+
app.route("/api/subtitles", subtitleRoutes);
|
|
9162
10725
|
app.route("/api/transcode", transcodeRoutes);
|
|
9163
10726
|
app.get("/api", (c) => c.json({ message: "Jukebox API" }));
|
|
9164
10727
|
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -9237,6 +10800,8 @@ if (isDevelopment) {
|
|
|
9237
10800
|
await viteServer.listen();
|
|
9238
10801
|
setupViteProxy(vitePort);
|
|
9239
10802
|
} else setupStaticServing();
|
|
10803
|
+
await scanManager.recoverInterruptedJobs();
|
|
10804
|
+
await scheduler.start();
|
|
9240
10805
|
const server = serve({
|
|
9241
10806
|
fetch: app.fetch,
|
|
9242
10807
|
port
|
|
@@ -9262,6 +10827,7 @@ function printWelcome(boundPort) {
|
|
|
9262
10827
|
function shutdown(signal) {
|
|
9263
10828
|
console.log(`\nReceived ${signal}, shutting down...`);
|
|
9264
10829
|
setTimeout(() => process.exit(1), 2e3).unref();
|
|
10830
|
+
scheduler.stop();
|
|
9265
10831
|
server.close(() => {
|
|
9266
10832
|
if (viteServer) viteServer.close().finally(() => process.exit(0));
|
|
9267
10833
|
else process.exit(0);
|