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.
- package/README.md +2 -2
- package/dist/client/assets/{Watch-DNFM488K.js → Watch-DE2orFm6.js} +27 -27
- package/dist/client/assets/{index-BYBYJ0kS.css → index-CLZKF1oG.css} +1 -1
- package/dist/client/assets/index-hOWDqZr2.js +58 -0
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/index.js +305 -306
- package/drizzle/0010_metadata_rename.sql +49 -0
- package/drizzle/meta/0009_snapshot.json +1 -1
- package/drizzle/meta/0010_snapshot.json +1059 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/dist/client/assets/index-COFfIwFC.js +0 -58
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6856
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6869
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6880
|
+
externalId: text("external_id"),
|
|
6894
6881
|
overview: text("overview"),
|
|
6895
6882
|
runtime: integer("runtime"),
|
|
6896
|
-
|
|
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
|
|
8047
|
+
const episodesSorted = episodeResults.map((result) => ({
|
|
7989
8048
|
...result,
|
|
7990
8049
|
type: "episode"
|
|
7991
|
-
}));
|
|
7992
|
-
const
|
|
8050
|
+
})).sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
|
8051
|
+
const seenShowIds = /* @__PURE__ */ new Set();
|
|
8052
|
+
const dedupedEpisodes = episodesSorted.filter((item) => {
|
|
8053
|
+
if (seenShowIds.has(item.show.id)) return false;
|
|
8054
|
+
seenShowIds.add(item.show.id);
|
|
8055
|
+
return true;
|
|
8056
|
+
});
|
|
8057
|
+
const combined = [...movies$2, ...dedupedEpisodes].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).slice(0, 20);
|
|
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/
|
|
8625
|
-
const
|
|
8626
|
-
|
|
8627
|
-
const
|
|
8628
|
-
"
|
|
8629
|
-
|
|
8630
|
-
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
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
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
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
|
-
|
|
8648
|
-
|
|
8649
|
-
|
|
8650
|
-
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
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
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
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
|
-
|
|
8694
|
-
|
|
8695
|
-
|
|
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
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
|
|
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
|
|
8774
|
+
async function fetchShowByExternalId(externalId) {
|
|
8745
8775
|
try {
|
|
8746
|
-
await
|
|
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
|
-
|
|
8796
|
-
|
|
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(
|
|
8798
|
+
async function fetchSeasonMetadata(externalId, seasonNumber) {
|
|
8817
8799
|
try {
|
|
8818
|
-
await
|
|
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
|
|
8922
|
-
if (!movie.
|
|
8923
|
-
console.log(` Fetching
|
|
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)
|
|
8926
|
-
|
|
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
|
-
|
|
8933
|
-
|
|
8903
|
+
posterUrl: metadata.posterUrl,
|
|
8904
|
+
backdropUrl: metadata.backdropUrl,
|
|
8934
8905
|
trailerUrl: metadata.trailerUrl
|
|
8935
8906
|
};
|
|
8936
|
-
} else if (!movie.
|
|
8937
|
-
console.log(`
|
|
8938
|
-
|
|
8939
|
-
|
|
8940
|
-
|
|
8941
|
-
|
|
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.
|
|
8922
|
+
title: movie.externalId ? movie.title : title,
|
|
8945
8923
|
fileName,
|
|
8946
8924
|
fileSize,
|
|
8947
8925
|
extension: ext,
|
|
8948
8926
|
updatedAt: now,
|
|
8949
|
-
...
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
8975
|
-
|
|
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
|
|
9217
|
+
let externalId;
|
|
9240
9218
|
if (existingShows.length > 0 && existingShows[0]) {
|
|
9241
|
-
|
|
9242
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
9256
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
9267
|
+
if (externalId !== null) metadataEpisodes = (await fetchSeasonMetadata(externalId, seasonNumber))?.episodes ?? [];
|
|
9276
9268
|
} else {
|
|
9277
9269
|
let seasonMetadata = null;
|
|
9278
|
-
if (
|
|
9279
|
-
console.log(` Fetching
|
|
9280
|
-
seasonMetadata = await fetchSeasonMetadata(
|
|
9281
|
-
|
|
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
|
-
|
|
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
|
|
9296
|
-
const episodeTitle =
|
|
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
|
-
|
|
9327
|
-
overview:
|
|
9328
|
-
runtime:
|
|
9329
|
-
|
|
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.
|
|
9732
|
-
movies.
|
|
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
|
-
|
|
9731
|
+
backdropUrl: row.backdrop_url,
|
|
9740
9732
|
id: row.id,
|
|
9741
9733
|
overview: row.overview,
|
|
9742
|
-
|
|
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.
|
|
9755
|
-
shows.
|
|
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
|
-
|
|
9754
|
+
backdropUrl: row.backdrop_url,
|
|
9763
9755
|
id: row.id,
|
|
9764
9756
|
overview: row.overview,
|
|
9765
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
9891
|
-
const
|
|
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);
|