jukebox-media-server 0.2.0 → 0.4.0

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