jukebox-media-server 0.3.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.
@@ -9,9 +9,9 @@ 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 { access, readFile, 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
15
  import { EventEmitter } from "node:events";
16
16
  import process$1 from "node:process";
17
17
  import os$1 from "node:os";
@@ -6789,24 +6789,11 @@ function migrate(db, config) {
6789
6789
  //#endregion
6790
6790
  //#region src/config/index.ts
6791
6791
  const configDirectory = join(homedir(), ".jukebox");
6792
- const configFilePath = join(configDirectory, "config.json");
6792
+ join(configDirectory, "config.json");
6793
6793
  const databasePath = join(configDirectory, "jukebox.db");
6794
6794
  function ensureConfigDirectory() {
6795
6795
  mkdirSync(configDirectory, { recursive: true });
6796
6796
  }
6797
- function getConfig() {
6798
- if (!existsSync(configFilePath)) return null;
6799
- try {
6800
- const raw = readFileSync(configFilePath, "utf-8");
6801
- return JSON.parse(raw);
6802
- } catch {
6803
- return null;
6804
- }
6805
- }
6806
- async function saveConfig(config) {
6807
- ensureConfigDirectory();
6808
- await writeFile(configFilePath, JSON.stringify(config, null, 2));
6809
- }
6810
6797
  //#endregion
6811
6798
  //#region src/database/schema.ts
6812
6799
  var schema_exports = /* @__PURE__ */ __exportAll({
@@ -6846,27 +6833,27 @@ const movies = sqliteTable("movies", {
6846
6833
  extension: text("extension"),
6847
6834
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
6848
6835
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
6849
- tmdbId: integer("tmdb_id"),
6836
+ externalId: text("external_id"),
6850
6837
  year: integer("year"),
6851
6838
  overview: text("overview"),
6852
6839
  runtime: integer("runtime"),
6853
6840
  genres: text("genres"),
6854
6841
  rating: real("rating"),
6855
- posterPath: text("poster_path"),
6856
- backdropPath: text("backdrop_path"),
6842
+ posterUrl: text("poster_url"),
6843
+ backdropUrl: text("backdrop_url"),
6857
6844
  trailerUrl: text("trailer_url")
6858
6845
  });
6859
6846
  const shows = sqliteTable("shows", {
6860
6847
  id: integer("id").primaryKey({ autoIncrement: true }),
6861
6848
  title: text("title").notNull(),
6862
6849
  folderPath: text("folder_path").notNull().unique(),
6863
- tmdbId: integer("tmdb_id"),
6850
+ externalId: text("external_id"),
6864
6851
  year: integer("year"),
6865
6852
  overview: text("overview"),
6866
6853
  genres: text("genres"),
6867
6854
  rating: real("rating"),
6868
- posterPath: text("poster_path"),
6869
- backdropPath: text("backdrop_path"),
6855
+ posterUrl: text("poster_url"),
6856
+ backdropUrl: text("backdrop_url"),
6870
6857
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
6871
6858
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
6872
6859
  });
@@ -6876,7 +6863,7 @@ const seasons = sqliteTable("seasons", {
6876
6863
  seasonNumber: integer("season_number").notNull(),
6877
6864
  name: text("name"),
6878
6865
  overview: text("overview"),
6879
- posterPath: text("poster_path"),
6866
+ posterUrl: text("poster_url"),
6880
6867
  episodeCount: integer("episode_count")
6881
6868
  });
6882
6869
  const episodes = sqliteTable("episodes", {
@@ -6890,10 +6877,10 @@ const episodes = sqliteTable("episodes", {
6890
6877
  fileName: text("file_name").notNull(),
6891
6878
  fileSize: integer("file_size"),
6892
6879
  extension: text("extension"),
6893
- tmdbId: integer("tmdb_id"),
6880
+ externalId: text("external_id"),
6894
6881
  overview: text("overview"),
6895
6882
  runtime: integer("runtime"),
6896
- stillPath: text("still_path"),
6883
+ stillUrl: text("still_url"),
6897
6884
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
6898
6885
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
6899
6886
  });
@@ -7624,6 +7611,78 @@ episodeStreamRoutes.get("/:id", async (context) => {
7624
7611
  } });
7625
7612
  });
7626
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
7627
7686
  //#region src/api/routes/hello.ts
7628
7687
  const helloRoutes = new Hono();
7629
7688
  helloRoutes.get("/", (c) => c.json({
@@ -7985,11 +8044,17 @@ progressRoutes.get("/continue-watching", async (context) => {
7985
8044
  ...result,
7986
8045
  type: "movie"
7987
8046
  }));
7988
- const episodes$2 = episodeResults.map((result) => ({
8047
+ const episodesSorted = episodeResults.map((result) => ({
7989
8048
  ...result,
7990
8049
  type: "episode"
7991
- }));
7992
- 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);
7993
8058
  return context.json(combined);
7994
8059
  });
7995
8060
  progressRoutes.get("/:movieId", async (c) => {
@@ -8621,218 +8686,124 @@ var Logger = class {
8621
8686
  };
8622
8687
  var log = new Logger();
8623
8688
  //#endregion
8624
- //#region src/services/settings.ts
8625
- const tmdbApiKeySettingKey = "tmdbApiKey";
8626
- const scanScheduleSettingKey = "scanSchedule";
8627
- const scanScheduleValues = [
8628
- "off",
8629
- "6h",
8630
- "12h",
8631
- "24h"
8632
- ];
8633
- function isScanScheduleValue(value) {
8634
- return scanScheduleValues.includes(value);
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}`);
8700
+ return await response.json();
8635
8701
  }
8636
- /**
8637
- * Read a setting value from the DB. Returns null when the key is not set.
8638
- *
8639
- * Accepts an optional database instance so tests and scripts can pass an
8640
- * in-memory SQLite instance instead of the singleton.
8641
- */
8642
- async function getSetting(key, database = db) {
8643
- const [row] = await database.select().from(settings).where(eq(settings.key, key)).limit(1);
8644
- return row?.value ?? null;
8702
+ var NotFoundError = class extends Error {
8703
+ constructor(path) {
8704
+ super(`Metadata API returned 404 for ${path}`);
8705
+ this.name = "NotFoundError";
8706
+ }
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
+ };
8645
8721
  }
8646
- /**
8647
- * Upsert a setting value.
8648
- *
8649
- * Uses a single INSERT ... ON CONFLICT so two concurrent callers don't both
8650
- * read null, both INSERT, and fail with a UNIQUE constraint on the second.
8651
- */
8652
- async function setSetting(key, value, database = db) {
8653
- const now = Date.now();
8654
- await database.insert(settings).values({
8655
- key,
8656
- value,
8657
- updatedAt: now
8658
- }).onConflictDoUpdate({
8659
- target: settings.key,
8660
- set: {
8661
- value,
8662
- updatedAt: now
8663
- }
8664
- });
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
+ };
8665
8734
  }
8666
- /**
8667
- * Resolve the TMDB API key using the dual-source strategy:
8668
- *
8669
- * 1. Prefer the value stored in the `settings` table.
8670
- * 2. Fall back to `~/.jukebox/config.json` for backward compatibility on the
8671
- * first boot after upgrading. Copy the JSON value into the DB once so
8672
- * subsequent lookups only touch the DB.
8673
- *
8674
- * Returns null when neither source has a key configured.
8675
- */
8676
- async function getTmdbApiKey(database = db) {
8677
- const stored = await getSetting(tmdbApiKeySettingKey, database);
8678
- if (stored !== null && stored.length > 0) return stored;
8679
- const legacyKey = getConfig()?.tmdbApiKey ?? null;
8680
- if (legacyKey !== null && legacyKey.length > 0) {
8681
- await setSetting(tmdbApiKeySettingKey, legacyKey, database);
8682
- return legacyKey;
8683
- }
8684
- return null;
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
+ };
8685
8749
  }
8686
- /**
8687
- * Persist the TMDB API key to the DB. Also mirror the value to the JSON
8688
- * config file so rollbacks to an older server build keep working.
8689
- */
8690
- async function setTmdbApiKey(apiKey, database = db) {
8691
- await setSetting(tmdbApiKeySettingKey, apiKey, database);
8750
+ async function fetchMovieMetadata(title, year) {
8692
8751
  try {
8693
- await saveConfig({ tmdbApiKey: apiKey });
8694
- } catch (error) {
8695
- log.warn("Couldn't mirror TMDB key to config.json — rollback safety is degraded. Check permissions on ~/.jukebox/.", error);
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}`);
8761
+ return null;
8696
8762
  }
8697
8763
  }
8698
- //#endregion
8699
- //#region src/services/tmdb.ts
8700
- const tmdbBaseUrl = "https://api.themoviedb.org/3";
8701
- async function getApiKey() {
8702
- const apiKey = await getTmdbApiKey() ?? process.env.TMDB_API_KEY ?? null;
8703
- if (!apiKey) throw new Error("TMDB API key is not configured");
8704
- return apiKey;
8705
- }
8706
- async function searchMovie(title, year) {
8707
- const apiKey = await getApiKey();
8708
- const params = new URLSearchParams({
8709
- api_key: apiKey,
8710
- query: title
8711
- });
8712
- if (year) params.set("year", year.toString());
8713
- const response = await fetch(`${tmdbBaseUrl}/search/movie?${params.toString()}`);
8714
- if (!response.ok) throw new Error(`TMDB search failed: ${response.statusText}`);
8715
- return (await response.json()).results;
8716
- }
8717
- async function getMovieDetails(tmdbId) {
8718
- const apiKey = await getApiKey();
8719
- const params = new URLSearchParams({ api_key: apiKey });
8720
- const response = await fetch(`${tmdbBaseUrl}/movie/${tmdbId}?${params.toString()}`);
8721
- if (!response.ok) throw new Error(`TMDB movie details failed: ${response.statusText}`);
8722
- return await response.json();
8723
- }
8724
- async function getMovieVideos(tmdbId) {
8725
- const apiKey = await getApiKey();
8726
- const params = new URLSearchParams({ api_key: apiKey });
8727
- const response = await fetch(`${tmdbBaseUrl}/movie/${tmdbId}/videos?${params.toString()}`);
8728
- if (!response.ok) throw new Error(`TMDB movie videos failed: ${response.statusText}`);
8729
- return (await response.json()).results;
8730
- }
8731
- function getTrailerUrl(videos) {
8732
- const youtubeVideos = videos.filter((v) => v.site === "YouTube");
8733
- for (const type of [
8734
- "Trailer",
8735
- "Teaser",
8736
- "Clip"
8737
- ]) {
8738
- const video = youtubeVideos.find((v) => v.type === type);
8739
- if (video) return `https://www.youtube.com/watch?v=${video.key}`;
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;
8740
8772
  }
8741
- if (youtubeVideos.length > 0 && youtubeVideos[0]) return `https://www.youtube.com/watch?v=${youtubeVideos[0].key}`;
8742
- return null;
8743
8773
  }
8744
- async function fetchMovieMetadata(title, year) {
8774
+ async function fetchShowByExternalId(externalId) {
8745
8775
  try {
8746
- await getApiKey();
8747
- } catch {
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}`);
8748
8781
  return null;
8749
8782
  }
8750
- const results = await searchMovie(title, year);
8751
- if (results.length === 0 || !results[0]) return null;
8752
- const bestMatch = results[0];
8753
- const [details, videos] = await Promise.all([getMovieDetails(bestMatch.id), getMovieVideos(bestMatch.id)]);
8754
- const releaseYear = details.release_date ? parseInt(details.release_date.split("-")[0] ?? "0", 10) || null : null;
8755
- return {
8756
- tmdbId: details.id,
8757
- title: details.title,
8758
- year: releaseYear,
8759
- overview: details.overview,
8760
- runtime: details.runtime,
8761
- genres: JSON.stringify(details.genres.map((g) => g.name)),
8762
- rating: details.vote_average,
8763
- posterPath: details.poster_path,
8764
- backdropPath: details.backdrop_path,
8765
- trailerUrl: getTrailerUrl(videos)
8766
- };
8767
- }
8768
- async function searchShow(title, year) {
8769
- const apiKey = await getApiKey();
8770
- const params = new URLSearchParams({
8771
- api_key: apiKey,
8772
- query: title
8773
- });
8774
- if (year) params.set("first_air_date_year", year.toString());
8775
- const response = await fetch(`${tmdbBaseUrl}/search/tv?${params.toString()}`);
8776
- if (!response.ok) throw new Error(`TMDB TV search failed: ${response.statusText}`);
8777
- return (await response.json()).results;
8778
- }
8779
- async function getShowDetails(tmdbId) {
8780
- const apiKey = await getApiKey();
8781
- const params = new URLSearchParams({ api_key: apiKey });
8782
- const response = await fetch(`${tmdbBaseUrl}/tv/${tmdbId}?${params.toString()}`);
8783
- if (!response.ok) throw new Error(`TMDB show details failed: ${response.statusText}`);
8784
- return await response.json();
8785
- }
8786
- async function getSeasonDetails(tmdbId, seasonNumber) {
8787
- const apiKey = await getApiKey();
8788
- const params = new URLSearchParams({ api_key: apiKey });
8789
- const response = await fetch(`${tmdbBaseUrl}/tv/${tmdbId}/season/${seasonNumber}?${params.toString()}`);
8790
- if (!response.ok) throw new Error(`TMDB season details failed: ${response.statusText}`);
8791
- return await response.json();
8792
8783
  }
8793
8784
  async function fetchShowMetadata(title, year) {
8794
8785
  try {
8795
- await getApiKey();
8796
- } 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}`);
8797
8795
  return null;
8798
8796
  }
8799
- const results = await searchShow(title, year);
8800
- if (results.length === 0 || !results[0]) return null;
8801
- const bestMatch = results[0];
8802
- const details = await getShowDetails(bestMatch.id);
8803
- const firstAirYear = details.first_air_date ? parseInt(details.first_air_date.split("-")[0] ?? "0", 10) || null : null;
8804
- return {
8805
- tmdbId: details.id,
8806
- title: details.name,
8807
- year: firstAirYear,
8808
- overview: details.overview,
8809
- genres: JSON.stringify(details.genres.map((g) => g.name)),
8810
- rating: details.vote_average,
8811
- posterPath: details.poster_path,
8812
- backdropPath: details.backdrop_path,
8813
- numberOfSeasons: details.number_of_seasons
8814
- };
8815
8797
  }
8816
- async function fetchSeasonMetadata(tmdbId, seasonNumber) {
8798
+ async function fetchSeasonMetadata(externalId, seasonNumber) {
8817
8799
  try {
8818
- await getApiKey();
8819
- } 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}`);
8820
8805
  return null;
8821
8806
  }
8822
- const details = await getSeasonDetails(tmdbId, seasonNumber);
8823
- return {
8824
- seasonNumber: details.season_number,
8825
- name: details.name,
8826
- overview: details.overview,
8827
- posterPath: details.poster_path,
8828
- episodes: details.episodes.map((episode) => ({
8829
- episodeNumber: episode.episode_number,
8830
- title: episode.name,
8831
- overview: episode.overview,
8832
- runtime: episode.runtime,
8833
- stillPath: episode.still_path
8834
- }))
8835
- };
8836
8807
  }
8837
8808
  //#endregion
8838
8809
  //#region src/services/subtitle-sync.ts
@@ -8918,35 +8889,42 @@ async function scanLibrary(libraryPath, onProgress) {
8918
8889
  if (existing.length > 0 && existing[0]) {
8919
8890
  const movie = existing[0];
8920
8891
  movieId = movie.id;
8921
- let tmdbData = {};
8922
- if (!movie.tmdbId) {
8923
- console.log(` Fetching TMDB metadata for: ${title}`);
8892
+ let metadataUpdate = {};
8893
+ if (!movie.externalId) {
8894
+ console.log(` Fetching metadata for: ${title}`);
8924
8895
  const metadata = await fetchMovieMetadata(title, year ?? void 0);
8925
- if (metadata) tmdbData = {
8926
- tmdbId: metadata.tmdbId,
8896
+ if (metadata) metadataUpdate = {
8897
+ externalId: metadata.externalId,
8927
8898
  year: metadata.year,
8928
8899
  overview: metadata.overview,
8929
8900
  runtime: metadata.runtime,
8930
8901
  genres: metadata.genres,
8931
8902
  rating: metadata.rating,
8932
- posterPath: metadata.posterPath,
8933
- backdropPath: metadata.backdropPath,
8903
+ posterUrl: metadata.posterUrl,
8904
+ backdropUrl: metadata.backdropUrl,
8934
8905
  trailerUrl: metadata.trailerUrl
8935
8906
  };
8936
- } else if (!movie.trailerUrl && movie.tmdbId) {
8937
- console.log(` Fetching trailer for: ${movie.title}`);
8938
- try {
8939
- const trailerUrl = getTrailerUrl(await getMovieVideos(movie.tmdbId));
8940
- if (trailerUrl) tmdbData = { trailerUrl };
8941
- } 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
+ };
8942
8920
  }
8943
8921
  await db.update(movies).set({
8944
- title: movie.tmdbId ? movie.title : title,
8922
+ title: movie.externalId ? movie.title : title,
8945
8923
  fileName,
8946
8924
  fileSize,
8947
8925
  extension: ext,
8948
8926
  updatedAt: now,
8949
- ...tmdbData
8927
+ ...metadataUpdate
8950
8928
  }).where(eq(movies.filePath, filePath));
8951
8929
  updated++;
8952
8930
  await onProgress?.({
@@ -8955,7 +8933,7 @@ async function scanLibrary(libraryPath, onProgress) {
8955
8933
  updated
8956
8934
  });
8957
8935
  } else {
8958
- console.log(` Fetching TMDB metadata for: ${title}`);
8936
+ console.log(` Fetching metadata for: ${title}`);
8959
8937
  const metadata = await fetchMovieMetadata(title, year ?? void 0);
8960
8938
  const newMovie = {
8961
8939
  title: metadata?.title ?? title,
@@ -8965,14 +8943,14 @@ async function scanLibrary(libraryPath, onProgress) {
8965
8943
  extension: ext,
8966
8944
  createdAt: now,
8967
8945
  updatedAt: now,
8968
- tmdbId: metadata?.tmdbId ?? null,
8946
+ externalId: metadata?.externalId ?? null,
8969
8947
  year: metadata?.year ?? year,
8970
8948
  overview: metadata?.overview ?? null,
8971
8949
  runtime: metadata?.runtime ?? null,
8972
8950
  genres: metadata?.genres ?? null,
8973
8951
  rating: metadata?.rating ?? null,
8974
- posterPath: metadata?.posterPath ?? null,
8975
- backdropPath: metadata?.backdropPath ?? null,
8952
+ posterUrl: metadata?.posterUrl ?? null,
8953
+ backdropUrl: metadata?.backdropUrl ?? null,
8976
8954
  trailerUrl: metadata?.trailerUrl ?? null
8977
8955
  };
8978
8956
  movieId = (await db.insert(movies).values(newMovie).returning({ id: movies.id }))[0]?.id ?? null;
@@ -9236,29 +9214,43 @@ async function scanShowLibrary(libraryPath, onProgress) {
9236
9214
  const folderPath = discoveredShow.folders[0] ?? "";
9237
9215
  const existingShows = await db.select().from(shows).where(eq(shows.folderPath, folderPath)).limit(1);
9238
9216
  let showId;
9239
- let tmdbId;
9217
+ let externalId;
9240
9218
  if (existingShows.length > 0 && existingShows[0]) {
9241
- showId = existingShows[0].id;
9242
- 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
+ }
9243
9235
  } else {
9244
- console.log(` Fetching TMDB metadata for show: ${name}`);
9236
+ console.log(` Fetching metadata for show: ${name}`);
9245
9237
  const metadata = await fetchShowMetadata(name, year ?? void 0);
9246
9238
  const now = /* @__PURE__ */ new Date();
9247
9239
  const newShow = {
9248
9240
  title: metadata?.title ?? name,
9249
9241
  folderPath,
9250
- tmdbId: metadata?.tmdbId ?? null,
9242
+ externalId: metadata?.externalId ?? null,
9251
9243
  year: metadata?.year ?? year ?? null,
9252
9244
  overview: metadata?.overview ?? null,
9253
9245
  genres: metadata?.genres ?? null,
9254
9246
  rating: metadata?.rating ?? null,
9255
- posterPath: metadata?.posterPath ?? null,
9256
- backdropPath: metadata?.backdropPath ?? null,
9247
+ posterUrl: metadata?.posterUrl ?? null,
9248
+ backdropUrl: metadata?.backdropUrl ?? null,
9257
9249
  createdAt: now,
9258
9250
  updatedAt: now
9259
9251
  };
9260
9252
  showId = (await db.insert(shows).values(newShow).returning({ id: shows.id }))[0]?.id ?? 0;
9261
- tmdbId = metadata?.tmdbId ?? null;
9253
+ externalId = metadata?.externalId ?? null;
9262
9254
  }
9263
9255
  const seasonMap = /* @__PURE__ */ new Map();
9264
9256
  for (const episode of episodes$1) {
@@ -9267,33 +9259,33 @@ async function scanShowLibrary(libraryPath, onProgress) {
9267
9259
  seasonMap.set(episode.seasonNumber, seasonEpisodes);
9268
9260
  }
9269
9261
  for (const [seasonNumber, seasonEpisodes] of seasonMap) {
9270
- let tmdbEpisodes = [];
9262
+ let metadataEpisodes = [];
9271
9263
  const existingSeasons = await db.select().from(seasons).where(and(eq(seasons.showId, showId), eq(seasons.seasonNumber, seasonNumber))).limit(1);
9272
9264
  let seasonId;
9273
9265
  if (existingSeasons.length > 0 && existingSeasons[0]) {
9274
9266
  seasonId = existingSeasons[0].id;
9275
- if (tmdbId !== null) tmdbEpisodes = (await fetchSeasonMetadata(tmdbId, seasonNumber))?.episodes ?? [];
9267
+ if (externalId !== null) metadataEpisodes = (await fetchSeasonMetadata(externalId, seasonNumber))?.episodes ?? [];
9276
9268
  } else {
9277
9269
  let seasonMetadata = null;
9278
- if (tmdbId !== null) {
9279
- console.log(` Fetching TMDB metadata for season ${seasonNumber} of: ${name}`);
9280
- seasonMetadata = await fetchSeasonMetadata(tmdbId, seasonNumber);
9281
- 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 ?? [];
9282
9274
  }
9283
9275
  const newSeason = {
9284
9276
  showId,
9285
9277
  seasonNumber,
9286
9278
  name: seasonMetadata?.name ?? null,
9287
9279
  overview: seasonMetadata?.overview ?? null,
9288
- posterPath: seasonMetadata?.posterPath ?? null,
9280
+ posterUrl: seasonMetadata?.posterUrl ?? null,
9289
9281
  episodeCount: seasonEpisodes.length
9290
9282
  };
9291
9283
  seasonId = (await db.insert(seasons).values(newSeason).returning({ id: seasons.id }))[0]?.id ?? 0;
9292
9284
  }
9293
9285
  for (const scannedEpisode of seasonEpisodes) {
9294
9286
  total++;
9295
- const tmdbEpisode = tmdbEpisodes.find((e) => e.episodeNumber === scannedEpisode.episodeNumber);
9296
- 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}`;
9297
9289
  const existingEpisodes = await db.select().from(episodes).where(eq(episodes.filePath, scannedEpisode.filePath)).limit(1);
9298
9290
  const now = /* @__PURE__ */ new Date();
9299
9291
  let episodeId;
@@ -9323,10 +9315,10 @@ async function scanShowLibrary(libraryPath, onProgress) {
9323
9315
  fileName: scannedEpisode.fileName,
9324
9316
  fileSize: scannedEpisode.fileSize,
9325
9317
  extension: scannedEpisode.extension,
9326
- tmdbId: tmdbEpisode ? null : null,
9327
- overview: tmdbEpisode?.overview ?? null,
9328
- runtime: tmdbEpisode?.runtime ?? null,
9329
- stillPath: tmdbEpisode?.stillPath ?? null,
9318
+ externalId: null,
9319
+ overview: metadataEpisode?.overview ?? null,
9320
+ runtime: metadataEpisode?.runtime ?? null,
9321
+ stillUrl: metadataEpisode?.stillUrl ?? null,
9330
9322
  createdAt: now,
9331
9323
  updatedAt: now
9332
9324
  };
@@ -9728,18 +9720,18 @@ function searchMovies(matchExpression, limit) {
9728
9720
  movies.title AS title,
9729
9721
  movies.year AS year,
9730
9722
  movies.overview AS overview,
9731
- movies.poster_path AS poster_path,
9732
- movies.backdrop_path AS backdrop_path
9723
+ movies.poster_url AS poster_url,
9724
+ movies.backdrop_url AS backdrop_url
9733
9725
  FROM movies_fts
9734
9726
  JOIN movies ON movies.rowid = movies_fts.rowid
9735
9727
  WHERE movies_fts MATCH ${matchExpression}
9736
9728
  ORDER BY bm25(movies_fts) ASC
9737
9729
  LIMIT ${limit}
9738
9730
  `).map((row) => ({
9739
- backdropPath: row.backdrop_path,
9731
+ backdropUrl: row.backdrop_url,
9740
9732
  id: row.id,
9741
9733
  overview: row.overview,
9742
- posterPath: row.poster_path,
9734
+ posterUrl: row.poster_url,
9743
9735
  title: row.title,
9744
9736
  year: row.year
9745
9737
  }));
@@ -9751,18 +9743,18 @@ function searchShows(matchExpression, limit) {
9751
9743
  shows.title AS title,
9752
9744
  shows.year AS year,
9753
9745
  shows.overview AS overview,
9754
- shows.poster_path AS poster_path,
9755
- shows.backdrop_path AS backdrop_path
9746
+ shows.poster_url AS poster_url,
9747
+ shows.backdrop_url AS backdrop_url
9756
9748
  FROM shows_fts
9757
9749
  JOIN shows ON shows.rowid = shows_fts.rowid
9758
9750
  WHERE shows_fts MATCH ${matchExpression}
9759
9751
  ORDER BY bm25(shows_fts) ASC
9760
9752
  LIMIT ${limit}
9761
9753
  `).map((row) => ({
9762
- backdropPath: row.backdrop_path,
9754
+ backdropUrl: row.backdrop_url,
9763
9755
  id: row.id,
9764
9756
  overview: row.overview,
9765
- posterPath: row.poster_path,
9757
+ posterUrl: row.poster_url,
9766
9758
  title: row.title,
9767
9759
  year: row.year
9768
9760
  }));
@@ -9776,7 +9768,7 @@ function searchEpisodes(matchExpression, limit) {
9776
9768
  episodes.episode_number AS episode_number,
9777
9769
  episodes.title AS title,
9778
9770
  episodes.overview AS overview,
9779
- episodes.still_path AS still_path,
9771
+ episodes.still_url AS still_url,
9780
9772
  shows.title AS show_title
9781
9773
  FROM episodes_fts
9782
9774
  JOIN episodes ON episodes.rowid = episodes_fts.rowid
@@ -9791,11 +9783,53 @@ function searchEpisodes(matchExpression, limit) {
9791
9783
  seasonNumber: row.season_number,
9792
9784
  showId: row.show_id,
9793
9785
  showTitle: row.show_title ?? "",
9794
- stillPath: row.still_path,
9786
+ stillUrl: row.still_url,
9795
9787
  title: row.title
9796
9788
  }));
9797
9789
  }
9798
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
9829
+ }
9830
+ });
9831
+ }
9832
+ //#endregion
9799
9833
  //#region src/services/scheduler.ts
9800
9834
  const millisecondsPerHour = 3600 * 1e3;
9801
9835
  function scheduleIntervalMilliseconds(value) {
@@ -9887,19 +9921,8 @@ function createScheduler(dependencies = {}) {
9887
9921
  const scheduler = createScheduler();
9888
9922
  //#endregion
9889
9923
  //#region src/api/routes/settings.ts
9890
- const tmdbConfigurationUrl = "https://api.themoviedb.org/3/configuration";
9891
- const reservedKeys = new Set([tmdbApiKeySettingKey, scanScheduleSettingKey]);
9892
- const reservedKeyRoute = {
9893
- [tmdbApiKeySettingKey]: "/api/settings/tmdb-key",
9894
- [scanScheduleSettingKey]: "/api/settings/scan-schedule"
9895
- };
9896
- async function verifyTmdbKey(apiKey) {
9897
- try {
9898
- return (await fetch(`${tmdbConfigurationUrl}?api_key=${encodeURIComponent(apiKey)}`)).ok;
9899
- } catch {
9900
- return false;
9901
- }
9902
- }
9924
+ const reservedKeys = new Set([scanScheduleSettingKey]);
9925
+ const reservedKeyRoute = { [scanScheduleSettingKey]: "/api/settings/scan-schedule" };
9903
9926
  async function pathIsReadable(path) {
9904
9927
  try {
9905
9928
  await access(path, constants.R_OK);
@@ -9932,26 +9955,6 @@ function libraryPathPrefixPattern(libraryPath) {
9932
9955
  return `${(resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`).replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`;
9933
9956
  }
9934
9957
  const settingsRoutes = new Hono();
9935
- settingsRoutes.get("/tmdb-key", async (context) => {
9936
- const apiKey = await getTmdbApiKey();
9937
- return context.json({
9938
- configured: apiKey !== null && apiKey.length > 0,
9939
- apiKey: apiKey ?? ""
9940
- });
9941
- });
9942
- settingsRoutes.put("/tmdb-key", async (context) => {
9943
- let body;
9944
- try {
9945
- body = await context.req.json();
9946
- } catch {
9947
- return context.json({ error: { message: "Invalid request body." } }, 400);
9948
- }
9949
- const apiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
9950
- if (apiKey.length === 0) return context.json({ error: { message: "TMDB API key is required. Get one at themoviedb.org/settings/api." } }, 400);
9951
- if (!await verifyTmdbKey(apiKey)) return context.json({ error: { message: "Couldn't save TMDB key — the key wasn't accepted by TMDB. Check it at themoviedb.org/settings/api." } }, 400);
9952
- await setTmdbApiKey(apiKey);
9953
- return context.json({ configured: true });
9954
- });
9955
9958
  settingsRoutes.get("/libraries", async (context) => {
9956
9959
  const libraries$2 = await db.select().from(libraries);
9957
9960
  return context.json(libraries$2.map((library) => ({
@@ -10088,11 +10091,8 @@ settingsRoutes.put("/:key", async (context) => {
10088
10091
  //#region src/api/routes/setup.ts
10089
10092
  const setupRoutes = new Hono();
10090
10093
  setupRoutes.get("/", async (context) => {
10091
- const apiKey = await getTmdbApiKey();
10092
10094
  const libraries$1 = await db.select().from(libraries);
10093
10095
  return context.json({
10094
- config: apiKey !== null ? { tmdbApiKey: apiKey } : null,
10095
- hasApiKey: apiKey !== null && apiKey !== "",
10096
10096
  libraries: libraries$1.map((library) => ({
10097
10097
  id: library.id,
10098
10098
  name: library.name,
@@ -10105,9 +10105,7 @@ setupRoutes.get("/", async (context) => {
10105
10105
  });
10106
10106
  setupRoutes.post("/complete", async (context) => {
10107
10107
  const body = await context.req.json();
10108
- if (!body.tmdbApiKey) return context.json({ error: { message: "TMDB API key is required" } }, 400);
10109
10108
  if (!body.libraries || body.libraries.length === 0) return context.json({ error: { message: "At least one library is required" } }, 400);
10110
- await setTmdbApiKey(body.tmdbApiKey);
10111
10109
  await db.delete(libraries);
10112
10110
  const now = /* @__PURE__ */ new Date();
10113
10111
  for (const library of body.libraries) await db.insert(libraries).values({
@@ -10717,6 +10715,7 @@ app.route("/api/scan", scanRoutes);
10717
10715
  app.route("/api/search", searchRoutes);
10718
10716
  app.route("/api/profiles", profileRoutes);
10719
10717
  app.route("/api/favorites", favoriteRoutes);
10718
+ app.route("/api/filesystem", filesystemRoutes);
10720
10719
  app.route("/api/settings", settingsRoutes);
10721
10720
  app.route("/api/setup", setupRoutes);
10722
10721
  app.route("/api/stream/episode", episodeStreamRoutes);