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.
@@ -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.length === 0) return c.json({ error: { message: "Movie not found" } }, 404);
7619
- return c.json(movie[0]);
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 = getConfig()?.tmdbApiKey ?? process.env.TMDB_API_KEY ?? null;
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/filename-parser.ts
8838
+ //#region src/services/subtitle-sync.ts
7959
8839
  /**
7960
- * Parse a movie filename to extract title and year.
7961
- * The year marks the boundary between the title and technical info.
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 parseFilename(fileName) {
7968
- const name = fileName.replace(/\.[^.]+$/, "");
7969
- const bracketYearMatch = name.match(/[([]((?:19|20)\d{2})[)\]]/);
7970
- if (bracketYearMatch?.[1]) {
7971
- const year = parseInt(bracketYearMatch[1], 10);
7972
- const yearIndex = bracketYearMatch.index ?? 0;
7973
- let title = name.substring(0, yearIndex);
7974
- title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
7975
- title = title.replace(/[-–—]+$/, "").trim();
7976
- return {
7977
- title,
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
- * Clean a movie filename to extract a readable title
8857
+ * Same as syncSubtitlesForMovie but for episodes. See note above.
8020
8858
  */
8021
- function cleanTitle(fileName) {
8022
- return parseFilename(fileName).title;
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
- const fullPath = join(dir, entry.name);
8045
- if (entry.isDirectory()) yield* scanDirectory(fullPath);
8046
- else if (entry.isFile()) {
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$1 = new Set([
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$1.has(ext)) return null;
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 episodes = [];
9114
+ const subtitleSiblings = [];
9115
+ const videoEntries = [];
8276
9116
  for (const entry of entries) {
8277
9117
  const extension = extname(entry).toLowerCase();
8278
- if (!videoExtensions.has(extension)) continue;
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
- if (existingEpisodes.length > 0) {
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("/stream", async (context) => {
8516
- const libraries$2 = await db.select().from(libraries);
8517
- if (libraries$2.length === 0) return context.json({ error: { message: "No libraries configured" } }, 400);
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
- let totalAdded = 0;
8520
- let totalUpdated = 0;
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({ index: i })
9579
+ data: JSON.stringify(event)
8528
9580
  });
8529
- try {
8530
- const onProgress = async (progress) => {
8531
- await stream.writeSSE({
8532
- event: "file-scanned",
8533
- data: JSON.stringify({
8534
- index: i,
8535
- ...progress
8536
- })
8537
- });
8538
- };
8539
- let result;
8540
- if (library.type === "movies") result = await scanLibrary(library.path, onProgress);
8541
- else result = await scanShowLibrary(library.path, onProgress);
8542
- totalAdded += result.added;
8543
- totalUpdated += result.updated;
8544
- totalFound += result.total;
8545
- await stream.writeSSE({
8546
- event: "library-complete",
8547
- data: JSON.stringify({
8548
- added: result.added,
8549
- index: i,
8550
- total: result.total,
8551
- updated: result.updated
8552
- })
8553
- });
8554
- } catch (error) {
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: "library-error",
8557
- data: JSON.stringify({
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
- await stream.writeSSE({
8565
- event: "scan-complete",
8566
- data: JSON.stringify({
8567
- added: totalAdded,
8568
- found: totalFound,
8569
- updated: totalUpdated
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 config = getConfig();
10091
+ const apiKey = await getTmdbApiKey();
8579
10092
  const libraries$1 = await db.select().from(libraries);
8580
10093
  return context.json({
8581
- config: config ? { tmdbApiKey: config.tmdbApiKey } : null,
8582
- hasApiKey: config?.tmdbApiKey !== void 0 && config.tmdbApiKey !== "",
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 saveConfig({ tmdbApiKey: body.tmdbApiKey });
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 laterEpisodes = (await db.select().from(episodes).where(eq(episodes.showId, showId)).orderBy(episodes.seasonNumber, episodes.episodeNumber)).filter((candidate) => {
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);