jukebox-media-server 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,30 @@
1
- import { createReadStream, existsSync, mkdirSync, readFileSync, 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
- import crypto$1 from "crypto";
8
- import { createServer as createServer$1 } from "vite";
7
+ import crypto$1, { randomBytes, scrypt, timingSafeEqual } from "crypto";
9
8
  import { versions } from "process";
10
9
  import Database from "better-sqlite3";
11
10
  import crypto$2 from "node:crypto";
12
11
  import fs from "node:fs";
13
- import { readdir, stat, writeFile } from "fs/promises";
14
- import { homedir } from "os";
12
+ import { access, readFile, readdir, stat, writeFile } from "fs/promises";
13
+ import os, { homedir } from "os";
14
+ import { promisify } from "util";
15
+ import { EventEmitter } from "node:events";
16
+ import process$1 from "node:process";
17
+ import os$1 from "node:os";
18
+ import tty from "node:tty";
19
+ import { spawn } from "child_process";
15
20
  //#region \0rolldown/runtime.js
21
+ var __create = Object.create;
16
22
  var __defProp = Object.defineProperty;
23
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
24
+ var __getOwnPropNames = Object.getOwnPropertyNames;
25
+ var __getProtoOf = Object.getPrototypeOf;
26
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
27
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
17
28
  var __exportAll = (all, no_symbols) => {
18
29
  let target = {};
19
30
  for (var name in all) __defProp(target, name, {
@@ -23,6 +34,20 @@ var __exportAll = (all, no_symbols) => {
23
34
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
24
35
  return target;
25
36
  };
37
+ var __copyProps = (to, from, except, desc) => {
38
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
39
+ key = keys[i];
40
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
41
+ get: ((k) => from[k]).bind(null, key),
42
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
43
+ });
44
+ }
45
+ return to;
46
+ };
47
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
48
+ value: mod,
49
+ enumerable: true
50
+ }) : target, mod));
26
51
  //#endregion
27
52
  //#region node_modules/@hono/node-server/dist/index.mjs
28
53
  var RequestError = class extends Error {
@@ -320,7 +345,7 @@ var drainIncoming = (incoming) => {
320
345
  incomingWithDrainState[incomingDraining] = true;
321
346
  if (incoming instanceof Http2ServerRequest) {
322
347
  try {
323
- incoming.stream?.close?.(constants.NGHTTP2_NO_ERROR);
348
+ incoming.stream?.close?.(constants$1.NGHTTP2_NO_ERROR);
324
349
  } catch {}
325
350
  return;
326
351
  }
@@ -2497,17 +2522,17 @@ var colorStatus = async (status) => {
2497
2522
  }
2498
2523
  return `${status}`;
2499
2524
  };
2500
- async function log(fn, prefix, method, path, status = 0, elapsed) {
2525
+ async function log$1(fn, prefix, method, path, status = 0, elapsed) {
2501
2526
  fn(prefix === "<--" ? `${prefix} ${method} ${path}` : `${prefix} ${method} ${path} ${await colorStatus(status)} ${elapsed}`);
2502
2527
  }
2503
- var logger = (fn = console.log) => {
2528
+ var logger$1 = (fn = console.log) => {
2504
2529
  return async function logger2(c, next) {
2505
2530
  const { method, url } = c.req;
2506
2531
  const path = url.slice(url.indexOf("/", 8));
2507
- await log(fn, "<--", method, path);
2532
+ await log$1(fn, "<--", method, path);
2508
2533
  const start = Date.now();
2509
2534
  await next();
2510
- await log(fn, "-->", method, path, c.res.status, time(start));
2535
+ await log$1(fn, "-->", method, path, c.res.status, time(start));
2511
2536
  };
2512
2537
  };
2513
2538
  //#endregion
@@ -3719,6 +3744,109 @@ function mapRelationalRow(tablesConfig, tableConfig, row, buildQueryResultSelect
3719
3744
  return result;
3720
3745
  }
3721
3746
  //#endregion
3747
+ //#region node_modules/hono/dist/helper/factory/index.js
3748
+ var createMiddleware = (middleware) => middleware;
3749
+ //#endregion
3750
+ //#region node_modules/hono/dist/utils/cookie.js
3751
+ var validCookieNameRegEx = /^[\w!#$%&'*.^`|~+-]+$/;
3752
+ var validCookieValueRegEx = /^[ !#-:<-[\]-~]*$/;
3753
+ var parse = (cookie, name) => {
3754
+ if (name && cookie.indexOf(name) === -1) return {};
3755
+ const pairs = cookie.trim().split(";");
3756
+ const parsedCookie = {};
3757
+ for (let pairStr of pairs) {
3758
+ pairStr = pairStr.trim();
3759
+ const valueStartPos = pairStr.indexOf("=");
3760
+ if (valueStartPos === -1) continue;
3761
+ const cookieName = pairStr.substring(0, valueStartPos).trim();
3762
+ if (name && name !== cookieName || !validCookieNameRegEx.test(cookieName)) continue;
3763
+ let cookieValue = pairStr.substring(valueStartPos + 1).trim();
3764
+ if (cookieValue.startsWith("\"") && cookieValue.endsWith("\"")) cookieValue = cookieValue.slice(1, -1);
3765
+ if (validCookieValueRegEx.test(cookieValue)) {
3766
+ parsedCookie[cookieName] = cookieValue.indexOf("%") !== -1 ? tryDecode(cookieValue, decodeURIComponent_) : cookieValue;
3767
+ if (name) break;
3768
+ }
3769
+ }
3770
+ return parsedCookie;
3771
+ };
3772
+ var _serialize = (name, value, opt = {}) => {
3773
+ let cookie = `${name}=${value}`;
3774
+ if (name.startsWith("__Secure-") && !opt.secure) throw new Error("__Secure- Cookie must have Secure attributes");
3775
+ if (name.startsWith("__Host-")) {
3776
+ if (!opt.secure) throw new Error("__Host- Cookie must have Secure attributes");
3777
+ if (opt.path !== "/") throw new Error("__Host- Cookie must have Path attributes with \"/\"");
3778
+ if (opt.domain) throw new Error("__Host- Cookie must not have Domain attributes");
3779
+ }
3780
+ if (opt && typeof opt.maxAge === "number" && opt.maxAge >= 0) {
3781
+ if (opt.maxAge > 3456e4) throw new Error("Cookies Max-Age SHOULD NOT be greater than 400 days (34560000 seconds) in duration.");
3782
+ cookie += `; Max-Age=${opt.maxAge | 0}`;
3783
+ }
3784
+ if (opt.domain && opt.prefix !== "host") cookie += `; Domain=${opt.domain}`;
3785
+ if (opt.path) cookie += `; Path=${opt.path}`;
3786
+ if (opt.expires) {
3787
+ if (opt.expires.getTime() - Date.now() > 3456e7) throw new Error("Cookies Expires SHOULD NOT be greater than 400 days (34560000 seconds) in the future.");
3788
+ cookie += `; Expires=${opt.expires.toUTCString()}`;
3789
+ }
3790
+ if (opt.httpOnly) cookie += "; HttpOnly";
3791
+ if (opt.secure) cookie += "; Secure";
3792
+ if (opt.sameSite) cookie += `; SameSite=${opt.sameSite.charAt(0).toUpperCase() + opt.sameSite.slice(1)}`;
3793
+ if (opt.priority) cookie += `; Priority=${opt.priority.charAt(0).toUpperCase() + opt.priority.slice(1)}`;
3794
+ if (opt.partitioned) {
3795
+ if (!opt.secure) throw new Error("Partitioned Cookie must have Secure attributes");
3796
+ cookie += "; Partitioned";
3797
+ }
3798
+ return cookie;
3799
+ };
3800
+ var serialize = (name, value, opt) => {
3801
+ value = encodeURIComponent(value);
3802
+ return _serialize(name, value, opt);
3803
+ };
3804
+ //#endregion
3805
+ //#region node_modules/hono/dist/helper/cookie/index.js
3806
+ var getCookie = (c, key, prefix) => {
3807
+ const cookie = c.req.raw.headers.get("Cookie");
3808
+ if (typeof key === "string") {
3809
+ if (!cookie) return;
3810
+ let finalKey = key;
3811
+ if (prefix === "secure") finalKey = "__Secure-" + key;
3812
+ else if (prefix === "host") finalKey = "__Host-" + key;
3813
+ return parse(cookie, finalKey)[finalKey];
3814
+ }
3815
+ if (!cookie) return {};
3816
+ return parse(cookie);
3817
+ };
3818
+ var generateCookie = (name, value, opt) => {
3819
+ let cookie;
3820
+ if (opt?.prefix === "secure") cookie = serialize("__Secure-" + name, value, {
3821
+ path: "/",
3822
+ ...opt,
3823
+ secure: true
3824
+ });
3825
+ else if (opt?.prefix === "host") cookie = serialize("__Host-" + name, value, {
3826
+ ...opt,
3827
+ path: "/",
3828
+ secure: true,
3829
+ domain: void 0
3830
+ });
3831
+ else cookie = serialize(name, value, {
3832
+ path: "/",
3833
+ ...opt
3834
+ });
3835
+ return cookie;
3836
+ };
3837
+ var setCookie = (c, name, value, opt) => {
3838
+ const cookie = generateCookie(name, value, opt);
3839
+ c.header("Set-Cookie", cookie, { append: true });
3840
+ };
3841
+ var deleteCookie = (c, name, opt) => {
3842
+ const deletedCookie = getCookie(c, name, opt?.prefix);
3843
+ setCookie(c, name, "", {
3844
+ ...opt,
3845
+ maxAge: 0
3846
+ });
3847
+ return deletedCookie;
3848
+ };
3849
+ //#endregion
3722
3850
  //#region node_modules/drizzle-orm/selection-proxy.js
3723
3851
  var SelectionProxyHandler = class SelectionProxyHandler {
3724
3852
  static [entityKind] = "SelectionProxyHandler";
@@ -4269,6 +4397,55 @@ const sqliteTable = (name, columns, extraConfig) => {
4269
4397
  return sqliteTableBase(name, columns, extraConfig);
4270
4398
  };
4271
4399
  //#endregion
4400
+ //#region node_modules/drizzle-orm/sqlite-core/indexes.js
4401
+ var IndexBuilderOn = class {
4402
+ constructor(name, unique) {
4403
+ this.name = name;
4404
+ this.unique = unique;
4405
+ }
4406
+ static [entityKind] = "SQLiteIndexBuilderOn";
4407
+ on(...columns) {
4408
+ return new IndexBuilder(this.name, columns, this.unique);
4409
+ }
4410
+ };
4411
+ var IndexBuilder = class {
4412
+ static [entityKind] = "SQLiteIndexBuilder";
4413
+ /** @internal */
4414
+ config;
4415
+ constructor(name, columns, unique) {
4416
+ this.config = {
4417
+ name,
4418
+ columns,
4419
+ unique,
4420
+ where: void 0
4421
+ };
4422
+ }
4423
+ /**
4424
+ * Condition for partial index.
4425
+ */
4426
+ where(condition) {
4427
+ this.config.where = condition;
4428
+ return this;
4429
+ }
4430
+ /** @internal */
4431
+ build(table) {
4432
+ return new Index(this.config, table);
4433
+ }
4434
+ };
4435
+ var Index = class {
4436
+ static [entityKind] = "SQLiteIndex";
4437
+ config;
4438
+ constructor(config, table) {
4439
+ this.config = {
4440
+ ...config,
4441
+ table
4442
+ };
4443
+ }
4444
+ };
4445
+ function uniqueIndex(name) {
4446
+ return new IndexBuilderOn(name, true);
4447
+ }
4448
+ //#endregion
4272
4449
  //#region node_modules/drizzle-orm/sqlite-core/utils.js
4273
4450
  function extractUsedTable(table) {
4274
4451
  if (is(table, SQLiteTable)) return [`${table[Table.Symbol.BaseName]}`];
@@ -6633,13 +6810,26 @@ async function saveConfig(config) {
6633
6810
  //#endregion
6634
6811
  //#region src/database/schema.ts
6635
6812
  var schema_exports = /* @__PURE__ */ __exportAll({
6813
+ authConfig: () => authConfig,
6636
6814
  episodes: () => episodes,
6815
+ favorites: () => favorites,
6637
6816
  libraries: () => libraries,
6638
6817
  movies: () => movies,
6818
+ profiles: () => profiles,
6819
+ scanJobs: () => scanJobs,
6639
6820
  seasons: () => seasons,
6821
+ sessions: () => sessions$1,
6822
+ settings: () => settings,
6640
6823
  shows: () => shows,
6824
+ subtitles: () => subtitles,
6641
6825
  watchProgress: () => watchProgress
6642
6826
  });
6827
+ const profiles = sqliteTable("profiles", {
6828
+ id: integer("id").primaryKey({ autoIncrement: true }),
6829
+ name: text("name").notNull().unique(),
6830
+ emoji: text("emoji").notNull(),
6831
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull()
6832
+ });
6643
6833
  const libraries = sqliteTable("libraries", {
6644
6834
  id: integer("id").primaryKey({ autoIncrement: true }),
6645
6835
  name: text("name").notNull(),
@@ -6709,23 +6899,624 @@ const episodes = sqliteTable("episodes", {
6709
6899
  });
6710
6900
  const watchProgress = sqliteTable("watch_progress", {
6711
6901
  id: integer("id").primaryKey({ autoIncrement: true }),
6712
- movieId: integer("movie_id").references(() => movies.id),
6713
- episodeId: integer("episode_id").references(() => episodes.id),
6902
+ profileId: integer("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
6903
+ movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
6904
+ episodeId: integer("episode_id").references(() => episodes.id, { onDelete: "cascade" }),
6714
6905
  currentTime: integer("current_time").notNull(),
6715
6906
  duration: integer("duration"),
6716
6907
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
6908
+ }, (table) => [uniqueIndex("watch_progress_profile_movie_idx").on(table.profileId, table.movieId), uniqueIndex("watch_progress_profile_episode_idx").on(table.profileId, table.episodeId)]);
6909
+ const favorites = sqliteTable("favorites", {
6910
+ id: integer("id").primaryKey({ autoIncrement: true }),
6911
+ profileId: integer("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
6912
+ movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
6913
+ showId: integer("show_id").references(() => shows.id, { onDelete: "cascade" }),
6914
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull()
6915
+ }, (table) => [uniqueIndex("favorites_profile_movie_idx").on(table.profileId, table.movieId), uniqueIndex("favorites_profile_show_idx").on(table.profileId, table.showId)]);
6916
+ const authConfig = sqliteTable("auth_config", {
6917
+ id: integer("id").primaryKey(),
6918
+ passwordHash: text("password_hash"),
6919
+ updatedAt: integer("updated_at").notNull()
6920
+ });
6921
+ const sessions$1 = sqliteTable("sessions", {
6922
+ id: text("id").primaryKey(),
6923
+ createdAt: integer("created_at").notNull(),
6924
+ expiresAt: integer("expires_at").notNull(),
6925
+ lastSeenAt: integer("last_seen_at").notNull(),
6926
+ userAgent: text("user_agent")
6927
+ });
6928
+ const settings = sqliteTable("settings", {
6929
+ key: text("key").primaryKey(),
6930
+ value: text("value").notNull(),
6931
+ updatedAt: integer("updated_at").notNull()
6932
+ });
6933
+ const scanJobs = sqliteTable("scan_jobs", {
6934
+ id: integer("id").primaryKey({ autoIncrement: true }),
6935
+ startedAt: integer("started_at", { mode: "timestamp" }).notNull(),
6936
+ endedAt: integer("ended_at", { mode: "timestamp" }),
6937
+ status: text("status").notNull(),
6938
+ added: integer("added").notNull().default(0),
6939
+ updated: integer("updated").notNull().default(0),
6940
+ total: integer("total").notNull().default(0),
6941
+ errorMessage: text("error_message")
6942
+ });
6943
+ const subtitles = sqliteTable("subtitles", {
6944
+ id: integer("id").primaryKey({ autoIncrement: true }),
6945
+ movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
6946
+ episodeId: integer("episode_id").references(() => episodes.id, { onDelete: "cascade" }),
6947
+ filePath: text("file_path").notNull(),
6948
+ language: text("language").notNull(),
6949
+ format: text("format").notNull()
6717
6950
  });
6718
6951
  //#endregion
6719
6952
  //#region src/database/index.ts
6720
6953
  ensureConfigDirectory();
6721
6954
  const sqlite = new Database(databasePath);
6955
+ sqlite.pragma("foreign_keys = ON");
6722
6956
  const db = drizzle(sqlite, { schema: schema_exports });
6723
6957
  const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
6724
6958
  migrate(db, { migrationsFolder: path.resolve(__dirname$2, "../../drizzle") });
6725
6959
  //#endregion
6960
+ //#region node_modules/tiny-invariant/dist/esm/tiny-invariant.js
6961
+ var import_dayjs_min = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
6962
+ (function(t, e) {
6963
+ "object" == typeof exports && "undefined" != typeof module ? module.exports = e() : "function" == typeof define && define.amd ? define(e) : (t = "undefined" != typeof globalThis ? globalThis : t || self).dayjs = e();
6964
+ })(exports, (function() {
6965
+ "use strict";
6966
+ var t = 1e3, e = 6e4, n = 36e5, r = "millisecond", i = "second", s = "minute", u = "hour", a = "day", o = "week", c = "month", f = "quarter", h = "year", d = "date", l = "Invalid Date", $ = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/, y = /\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g, M = {
6967
+ name: "en",
6968
+ weekdays: "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),
6969
+ months: "January_February_March_April_May_June_July_August_September_October_November_December".split("_"),
6970
+ ordinal: function(t) {
6971
+ var e = [
6972
+ "th",
6973
+ "st",
6974
+ "nd",
6975
+ "rd"
6976
+ ], n = t % 100;
6977
+ return "[" + t + (e[(n - 20) % 10] || e[n] || e[0]) + "]";
6978
+ }
6979
+ }, m = function(t, e, n) {
6980
+ var r = String(t);
6981
+ return !r || r.length >= e ? t : "" + Array(e + 1 - r.length).join(n) + t;
6982
+ }, v = {
6983
+ s: m,
6984
+ z: function(t) {
6985
+ var e = -t.utcOffset(), n = Math.abs(e), r = Math.floor(n / 60), i = n % 60;
6986
+ return (e <= 0 ? "+" : "-") + m(r, 2, "0") + ":" + m(i, 2, "0");
6987
+ },
6988
+ m: function t(e, n) {
6989
+ if (e.date() < n.date()) return -t(n, e);
6990
+ var r = 12 * (n.year() - e.year()) + (n.month() - e.month()), i = e.clone().add(r, c), s = n - i < 0, u = e.clone().add(r + (s ? -1 : 1), c);
6991
+ return +(-(r + (n - i) / (s ? i - u : u - i)) || 0);
6992
+ },
6993
+ a: function(t) {
6994
+ return t < 0 ? Math.ceil(t) || 0 : Math.floor(t);
6995
+ },
6996
+ p: function(t) {
6997
+ return {
6998
+ M: c,
6999
+ y: h,
7000
+ w: o,
7001
+ d: a,
7002
+ D: d,
7003
+ h: u,
7004
+ m: s,
7005
+ s: i,
7006
+ ms: r,
7007
+ Q: f
7008
+ }[t] || String(t || "").toLowerCase().replace(/s$/, "");
7009
+ },
7010
+ u: function(t) {
7011
+ return void 0 === t;
7012
+ }
7013
+ }, g = "en", D = {};
7014
+ D[g] = M;
7015
+ var p = "$isDayjsObject", S = function(t) {
7016
+ return t instanceof _ || !(!t || !t[p]);
7017
+ }, w = function t(e, n, r) {
7018
+ var i;
7019
+ if (!e) return g;
7020
+ if ("string" == typeof e) {
7021
+ var s = e.toLowerCase();
7022
+ D[s] && (i = s), n && (D[s] = n, i = s);
7023
+ var u = e.split("-");
7024
+ if (!i && u.length > 1) return t(u[0]);
7025
+ } else {
7026
+ var a = e.name;
7027
+ D[a] = e, i = a;
7028
+ }
7029
+ return !r && i && (g = i), i || !r && g;
7030
+ }, O = function(t, e) {
7031
+ if (S(t)) return t.clone();
7032
+ var n = "object" == typeof e ? e : {};
7033
+ return n.date = t, n.args = arguments, new _(n);
7034
+ }, b = v;
7035
+ b.l = w, b.i = S, b.w = function(t, e) {
7036
+ return O(t, {
7037
+ locale: e.$L,
7038
+ utc: e.$u,
7039
+ x: e.$x,
7040
+ $offset: e.$offset
7041
+ });
7042
+ };
7043
+ var _ = function() {
7044
+ function M(t) {
7045
+ this.$L = w(t.locale, null, !0), this.parse(t), this.$x = this.$x || t.x || {}, this[p] = !0;
7046
+ }
7047
+ var m = M.prototype;
7048
+ return m.parse = function(t) {
7049
+ this.$d = function(t) {
7050
+ var e = t.date, n = t.utc;
7051
+ if (null === e) return /* @__PURE__ */ new Date(NaN);
7052
+ if (b.u(e)) return /* @__PURE__ */ new Date();
7053
+ if (e instanceof Date) return new Date(e);
7054
+ if ("string" == typeof e && !/Z$/i.test(e)) {
7055
+ var r = e.match($);
7056
+ if (r) {
7057
+ var i = r[2] - 1 || 0, s = (r[7] || "0").substring(0, 3);
7058
+ return n ? new Date(Date.UTC(r[1], i, r[3] || 1, r[4] || 0, r[5] || 0, r[6] || 0, s)) : new Date(r[1], i, r[3] || 1, r[4] || 0, r[5] || 0, r[6] || 0, s);
7059
+ }
7060
+ }
7061
+ return new Date(e);
7062
+ }(t), this.init();
7063
+ }, m.init = function() {
7064
+ var t = this.$d;
7065
+ this.$y = t.getFullYear(), this.$M = t.getMonth(), this.$D = t.getDate(), this.$W = t.getDay(), this.$H = t.getHours(), this.$m = t.getMinutes(), this.$s = t.getSeconds(), this.$ms = t.getMilliseconds();
7066
+ }, m.$utils = function() {
7067
+ return b;
7068
+ }, m.isValid = function() {
7069
+ return !(this.$d.toString() === l);
7070
+ }, m.isSame = function(t, e) {
7071
+ var n = O(t);
7072
+ return this.startOf(e) <= n && n <= this.endOf(e);
7073
+ }, m.isAfter = function(t, e) {
7074
+ return O(t) < this.startOf(e);
7075
+ }, m.isBefore = function(t, e) {
7076
+ return this.endOf(e) < O(t);
7077
+ }, m.$g = function(t, e, n) {
7078
+ return b.u(t) ? this[e] : this.set(n, t);
7079
+ }, m.unix = function() {
7080
+ return Math.floor(this.valueOf() / 1e3);
7081
+ }, m.valueOf = function() {
7082
+ return this.$d.getTime();
7083
+ }, m.startOf = function(t, e) {
7084
+ var n = this, r = !!b.u(e) || e, f = b.p(t), l = function(t, e) {
7085
+ var i = b.w(n.$u ? Date.UTC(n.$y, e, t) : new Date(n.$y, e, t), n);
7086
+ return r ? i : i.endOf(a);
7087
+ }, $ = function(t, e) {
7088
+ return b.w(n.toDate()[t].apply(n.toDate("s"), (r ? [
7089
+ 0,
7090
+ 0,
7091
+ 0,
7092
+ 0
7093
+ ] : [
7094
+ 23,
7095
+ 59,
7096
+ 59,
7097
+ 999
7098
+ ]).slice(e)), n);
7099
+ }, y = this.$W, M = this.$M, m = this.$D, v = "set" + (this.$u ? "UTC" : "");
7100
+ switch (f) {
7101
+ case h: return r ? l(1, 0) : l(31, 11);
7102
+ case c: return r ? l(1, M) : l(0, M + 1);
7103
+ case o:
7104
+ var g = this.$locale().weekStart || 0, D = (y < g ? y + 7 : y) - g;
7105
+ return l(r ? m - D : m + (6 - D), M);
7106
+ case a:
7107
+ case d: return $(v + "Hours", 0);
7108
+ case u: return $(v + "Minutes", 1);
7109
+ case s: return $(v + "Seconds", 2);
7110
+ case i: return $(v + "Milliseconds", 3);
7111
+ default: return this.clone();
7112
+ }
7113
+ }, m.endOf = function(t) {
7114
+ return this.startOf(t, !1);
7115
+ }, m.$set = function(t, e) {
7116
+ var n, o = b.p(t), f = "set" + (this.$u ? "UTC" : ""), l = (n = {}, n[a] = f + "Date", n[d] = f + "Date", n[c] = f + "Month", n[h] = f + "FullYear", n[u] = f + "Hours", n[s] = f + "Minutes", n[i] = f + "Seconds", n[r] = f + "Milliseconds", n)[o], $ = o === a ? this.$D + (e - this.$W) : e;
7117
+ if (o === c || o === h) {
7118
+ var y = this.clone().set(d, 1);
7119
+ y.$d[l]($), y.init(), this.$d = y.set(d, Math.min(this.$D, y.daysInMonth())).$d;
7120
+ } else l && this.$d[l]($);
7121
+ return this.init(), this;
7122
+ }, m.set = function(t, e) {
7123
+ return this.clone().$set(t, e);
7124
+ }, m.get = function(t) {
7125
+ return this[b.p(t)]();
7126
+ }, m.add = function(r, f) {
7127
+ var d, l = this;
7128
+ r = Number(r);
7129
+ var $ = b.p(f), y = function(t) {
7130
+ var e = O(l);
7131
+ return b.w(e.date(e.date() + Math.round(t * r)), l);
7132
+ };
7133
+ if ($ === c) return this.set(c, this.$M + r);
7134
+ if ($ === h) return this.set(h, this.$y + r);
7135
+ if ($ === a) return y(1);
7136
+ if ($ === o) return y(7);
7137
+ var M = (d = {}, d[s] = e, d[u] = n, d[i] = t, d)[$] || 1, m = this.$d.getTime() + r * M;
7138
+ return b.w(m, this);
7139
+ }, m.subtract = function(t, e) {
7140
+ return this.add(-1 * t, e);
7141
+ }, m.format = function(t) {
7142
+ var e = this, n = this.$locale();
7143
+ if (!this.isValid()) return n.invalidDate || l;
7144
+ var r = t || "YYYY-MM-DDTHH:mm:ssZ", i = b.z(this), s = this.$H, u = this.$m, a = this.$M, o = n.weekdays, c = n.months, f = n.meridiem, h = function(t, n, i, s) {
7145
+ return t && (t[n] || t(e, r)) || i[n].slice(0, s);
7146
+ }, d = function(t) {
7147
+ return b.s(s % 12 || 12, t, "0");
7148
+ }, $ = f || function(t, e, n) {
7149
+ var r = t < 12 ? "AM" : "PM";
7150
+ return n ? r.toLowerCase() : r;
7151
+ };
7152
+ return r.replace(y, (function(t, r) {
7153
+ return r || function(t) {
7154
+ switch (t) {
7155
+ case "YY": return String(e.$y).slice(-2);
7156
+ case "YYYY": return b.s(e.$y, 4, "0");
7157
+ case "M": return a + 1;
7158
+ case "MM": return b.s(a + 1, 2, "0");
7159
+ case "MMM": return h(n.monthsShort, a, c, 3);
7160
+ case "MMMM": return h(c, a);
7161
+ case "D": return e.$D;
7162
+ case "DD": return b.s(e.$D, 2, "0");
7163
+ case "d": return String(e.$W);
7164
+ case "dd": return h(n.weekdaysMin, e.$W, o, 2);
7165
+ case "ddd": return h(n.weekdaysShort, e.$W, o, 3);
7166
+ case "dddd": return o[e.$W];
7167
+ case "H": return String(s);
7168
+ case "HH": return b.s(s, 2, "0");
7169
+ case "h": return d(1);
7170
+ case "hh": return d(2);
7171
+ case "a": return $(s, u, !0);
7172
+ case "A": return $(s, u, !1);
7173
+ case "m": return String(u);
7174
+ case "mm": return b.s(u, 2, "0");
7175
+ case "s": return String(e.$s);
7176
+ case "ss": return b.s(e.$s, 2, "0");
7177
+ case "SSS": return b.s(e.$ms, 3, "0");
7178
+ case "Z": return i;
7179
+ }
7180
+ return null;
7181
+ }(t) || i.replace(":", "");
7182
+ }));
7183
+ }, m.utcOffset = function() {
7184
+ return 15 * -Math.round(this.$d.getTimezoneOffset() / 15);
7185
+ }, m.diff = function(r, d, l) {
7186
+ var $, y = this, M = b.p(d), m = O(r), v = (m.utcOffset() - this.utcOffset()) * e, g = this - m, D = function() {
7187
+ return b.m(y, m);
7188
+ };
7189
+ switch (M) {
7190
+ case h:
7191
+ $ = D() / 12;
7192
+ break;
7193
+ case c:
7194
+ $ = D();
7195
+ break;
7196
+ case f:
7197
+ $ = D() / 3;
7198
+ break;
7199
+ case o:
7200
+ $ = (g - v) / 6048e5;
7201
+ break;
7202
+ case a:
7203
+ $ = (g - v) / 864e5;
7204
+ break;
7205
+ case u:
7206
+ $ = g / n;
7207
+ break;
7208
+ case s:
7209
+ $ = g / e;
7210
+ break;
7211
+ case i:
7212
+ $ = g / t;
7213
+ break;
7214
+ default: $ = g;
7215
+ }
7216
+ return l ? $ : b.a($);
7217
+ }, m.daysInMonth = function() {
7218
+ return this.endOf(c).$D;
7219
+ }, m.$locale = function() {
7220
+ return D[this.$L];
7221
+ }, m.locale = function(t, e) {
7222
+ if (!t) return this.$L;
7223
+ var n = this.clone(), r = w(t, e, !0);
7224
+ return r && (n.$L = r), n;
7225
+ }, m.clone = function() {
7226
+ return b.w(this.$d, this);
7227
+ }, m.toDate = function() {
7228
+ return new Date(this.valueOf());
7229
+ }, m.toJSON = function() {
7230
+ return this.isValid() ? this.toISOString() : null;
7231
+ }, m.toISOString = function() {
7232
+ return this.$d.toISOString();
7233
+ }, m.toString = function() {
7234
+ return this.$d.toUTCString();
7235
+ }, M;
7236
+ }(), k = _.prototype;
7237
+ return O.prototype = k, [
7238
+ ["$ms", r],
7239
+ ["$s", i],
7240
+ ["$m", s],
7241
+ ["$H", u],
7242
+ ["$W", a],
7243
+ ["$M", c],
7244
+ ["$y", h],
7245
+ ["$D", d]
7246
+ ].forEach((function(t) {
7247
+ k[t[1]] = function(e) {
7248
+ return this.$g(e, t[0], t[1]);
7249
+ };
7250
+ })), O.extend = function(t, e) {
7251
+ return t.$i || (t(e, _, O), t.$i = !0), O;
7252
+ }, O.locale = w, O.isDayjs = S, O.unix = function(t) {
7253
+ return O(1e3 * t);
7254
+ }, O.en = D[g], O.Ls = D, O.p = {}, O;
7255
+ }));
7256
+ })))(), 1);
7257
+ var isProduction = process.env.NODE_ENV === "production";
7258
+ var prefix = "Invariant failed";
7259
+ function invariant(condition, message) {
7260
+ if (condition) return;
7261
+ if (isProduction) throw new Error(prefix);
7262
+ var provided = typeof message === "function" ? message() : message;
7263
+ var value = provided ? "".concat(prefix, ": ").concat(provided) : prefix;
7264
+ throw new Error(value);
7265
+ }
7266
+ //#endregion
7267
+ //#region src/api/routes/auth.ts
7268
+ const scryptAsync = promisify(scrypt);
7269
+ const sessionCookieName = "jukebox_session";
7270
+ const rateLimitWindowMs = 900 * 1e3;
7271
+ const rateLimitBuckets = /* @__PURE__ */ new Map();
7272
+ function clientIp(headers) {
7273
+ const forwarded = headers.get("x-forwarded-for");
7274
+ if (forwarded) {
7275
+ const first = forwarded.split(",")[0]?.trim();
7276
+ if (first) return first;
7277
+ }
7278
+ return headers.get("x-real-ip") ?? "unknown";
7279
+ }
7280
+ function checkRateLimit(ip) {
7281
+ const now = Date.now();
7282
+ const cutoff = now - rateLimitWindowMs;
7283
+ const entry = rateLimitBuckets.get(ip) ?? { attempts: [] };
7284
+ entry.attempts = entry.attempts.filter((time) => time > cutoff);
7285
+ if (entry.attempts.length >= 5) {
7286
+ const retryAfterMs = (entry.attempts[0] ?? now) + rateLimitWindowMs - now;
7287
+ const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1e3));
7288
+ rateLimitBuckets.set(ip, entry);
7289
+ return {
7290
+ allowed: false,
7291
+ retryAfterSeconds
7292
+ };
7293
+ }
7294
+ rateLimitBuckets.set(ip, entry);
7295
+ return {
7296
+ allowed: true,
7297
+ retryAfterSeconds: 0
7298
+ };
7299
+ }
7300
+ function recordFailedAttempt(ip) {
7301
+ const entry = rateLimitBuckets.get(ip) ?? { attempts: [] };
7302
+ entry.attempts.push(Date.now());
7303
+ rateLimitBuckets.set(ip, entry);
7304
+ }
7305
+ function clearAttempts(ip) {
7306
+ rateLimitBuckets.delete(ip);
7307
+ }
7308
+ async function loadAuthConfig() {
7309
+ const [existing] = await db.select().from(authConfig).where(eq(authConfig.id, 1)).limit(1);
7310
+ if (existing) return existing;
7311
+ const [created] = await db.insert(authConfig).values({
7312
+ id: 1,
7313
+ passwordHash: null,
7314
+ updatedAt: Date.now()
7315
+ }).returning();
7316
+ invariant(created, "Failed to initialize auth config.");
7317
+ return created;
7318
+ }
7319
+ const scryptKeyLength = 64;
7320
+ async function hashPassword(password) {
7321
+ const salt = randomBytes(16);
7322
+ const derived = await scryptAsync(password, salt, scryptKeyLength);
7323
+ return `scrypt$${salt.toString("base64")}$${derived.toString("base64")}`;
7324
+ }
7325
+ async function verifyPassword(password, stored) {
7326
+ const parts = stored.split("$");
7327
+ if (parts.length !== 3 || parts[0] !== "scrypt") return false;
7328
+ try {
7329
+ const salt = Buffer.from(parts[1] ?? "", "base64");
7330
+ const expected = Buffer.from(parts[2] ?? "", "base64");
7331
+ const derived = await scryptAsync(password, salt, expected.length);
7332
+ if (derived.length !== expected.length) return false;
7333
+ return timingSafeEqual(derived, expected);
7334
+ } catch {
7335
+ return false;
7336
+ }
7337
+ }
7338
+ function createSessionToken() {
7339
+ return randomBytes(32).toString("base64url");
7340
+ }
7341
+ async function createSession(userAgent) {
7342
+ const now = (0, import_dayjs_min.default)();
7343
+ const token = createSessionToken();
7344
+ await db.insert(sessions$1).values({
7345
+ id: token,
7346
+ createdAt: now.valueOf(),
7347
+ expiresAt: now.add(30, "day").valueOf(),
7348
+ lastSeenAt: now.valueOf(),
7349
+ userAgent
7350
+ });
7351
+ return token;
7352
+ }
7353
+ async function deleteAllSessions() {
7354
+ await db.delete(sessions$1);
7355
+ }
7356
+ async function deleteSession(id) {
7357
+ await db.delete(sessions$1).where(eq(sessions$1.id, id));
7358
+ }
7359
+ function sessionCookieOptions() {
7360
+ return {
7361
+ httpOnly: true,
7362
+ maxAge: 720 * 60 * 60,
7363
+ path: "/",
7364
+ sameSite: "Lax",
7365
+ secure: process.env.NODE_ENV === "production"
7366
+ };
7367
+ }
7368
+ const authRoutes = new Hono();
7369
+ authRoutes.get("/status", async (context) => {
7370
+ if (!((await loadAuthConfig()).passwordHash !== null)) return context.json({
7371
+ enabled: false,
7372
+ authenticated: true
7373
+ });
7374
+ const cookie = getCookie(context, sessionCookieName);
7375
+ if (!cookie) return context.json({
7376
+ enabled: true,
7377
+ authenticated: false
7378
+ });
7379
+ const [session] = await db.select().from(sessions$1).where(eq(sessions$1.id, cookie)).limit(1);
7380
+ const authenticated = session !== void 0 && session.expiresAt > Date.now();
7381
+ return context.json({
7382
+ enabled: true,
7383
+ authenticated
7384
+ });
7385
+ });
7386
+ authRoutes.post("/login", async (context) => {
7387
+ const ip = clientIp(context.req.raw.headers);
7388
+ const limit = checkRateLimit(ip);
7389
+ if (!limit.allowed) {
7390
+ const minutes = Math.ceil(limit.retryAfterSeconds / 60);
7391
+ context.header("Retry-After", String(limit.retryAfterSeconds));
7392
+ return context.json({ error: { message: `Too many attempts. Try again in ${minutes} ${minutes === 1 ? "minute" : "minutes"}.` } }, 429);
7393
+ }
7394
+ let body;
7395
+ try {
7396
+ body = await context.req.json();
7397
+ } catch {
7398
+ return context.json({ error: { message: "Invalid request body." } }, 400);
7399
+ }
7400
+ const password = typeof body.password === "string" ? body.password : "";
7401
+ if (password.length === 0) return context.json({ error: { message: "Incorrect password." } }, 401);
7402
+ const config = await loadAuthConfig();
7403
+ if (config.passwordHash === null) return context.json({ error: { message: "Password login is disabled on this server." } }, 400);
7404
+ if (!await verifyPassword(password, config.passwordHash)) {
7405
+ recordFailedAttempt(ip);
7406
+ return context.json({ error: { message: "Incorrect password." } }, 401);
7407
+ }
7408
+ clearAttempts(ip);
7409
+ setCookie(context, sessionCookieName, await createSession(context.req.header("user-agent") ?? null), sessionCookieOptions());
7410
+ return context.body(null, 204);
7411
+ });
7412
+ authRoutes.post("/logout", async (context) => {
7413
+ const token = getCookie(context, sessionCookieName);
7414
+ if (token) await deleteSession(token);
7415
+ deleteCookie(context, sessionCookieName, { path: "/" });
7416
+ return context.body(null, 204);
7417
+ });
7418
+ authRoutes.post("/password", async (context) => {
7419
+ let body;
7420
+ try {
7421
+ body = await context.req.json();
7422
+ } catch {
7423
+ return context.json({ error: { message: "Invalid request body." } }, 400);
7424
+ }
7425
+ const newPassword = typeof body.newPassword === "string" ? body.newPassword : "";
7426
+ const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : "";
7427
+ const config = await loadAuthConfig();
7428
+ if (config.passwordHash !== null) {
7429
+ if (!await verifyPassword(currentPassword, config.passwordHash ?? "")) return context.json({ error: { message: "Current password is incorrect." } }, 401);
7430
+ }
7431
+ if (newPassword.length === 0) {
7432
+ await db.update(authConfig).set({
7433
+ passwordHash: null,
7434
+ updatedAt: Date.now()
7435
+ }).where(eq(authConfig.id, 1));
7436
+ await deleteAllSessions();
7437
+ deleteCookie(context, sessionCookieName, { path: "/" });
7438
+ return context.json({ enabled: false });
7439
+ }
7440
+ if (newPassword.length < 8) return context.json({ error: { message: `Choose a password of at least 8 characters.` } }, 400);
7441
+ const hash = await hashPassword(newPassword);
7442
+ await db.update(authConfig).set({
7443
+ passwordHash: hash,
7444
+ updatedAt: Date.now()
7445
+ }).where(eq(authConfig.id, 1));
7446
+ await deleteAllSessions();
7447
+ setCookie(context, sessionCookieName, await createSession(context.req.header("user-agent") ?? null), sessionCookieOptions());
7448
+ return context.json({ enabled: true });
7449
+ });
7450
+ //#endregion
7451
+ //#region src/api/middleware/auth.ts
7452
+ const lastSeenThrottleMs = 60 * 1e3;
7453
+ let lastSweepAt = 0;
7454
+ const sweepIntervalMs = 300 * 1e3;
7455
+ async function sweepExpiredSessions(now) {
7456
+ if (now - lastSweepAt < sweepIntervalMs) return;
7457
+ lastSweepAt = now;
7458
+ await db.delete(sessions$1).where(lt(sessions$1.expiresAt, now));
7459
+ }
7460
+ const authMiddleware = createMiddleware(async (context, next) => {
7461
+ const pathname = new URL(context.req.url).pathname;
7462
+ if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/setup")) return next();
7463
+ const [config] = await db.select().from(authConfig).where(eq(authConfig.id, 1)).limit(1);
7464
+ if (!config || config.passwordHash === null) return next();
7465
+ const token = getCookie(context, sessionCookieName);
7466
+ if (!token) return context.json({ error: { message: "Authentication required." } }, 401);
7467
+ const [session] = await db.select().from(sessions$1).where(eq(sessions$1.id, token)).limit(1);
7468
+ const now = Date.now();
7469
+ await sweepExpiredSessions(now);
7470
+ if (!session || session.expiresAt <= now) {
7471
+ if (session) await db.delete(sessions$1).where(eq(sessions$1.id, session.id));
7472
+ return context.json({ error: { message: "Your session has expired. Please sign in again." } }, 401);
7473
+ }
7474
+ if (now - session.lastSeenAt > lastSeenThrottleMs) await db.update(sessions$1).set({ lastSeenAt: now }).where(eq(sessions$1.id, session.id));
7475
+ return next();
7476
+ });
7477
+ //#endregion
7478
+ //#region src/api/middleware/profile.ts
7479
+ const profileCookieName = "jukebox_profile_id";
7480
+ const oneYearSeconds = 3600 * 24 * 365;
7481
+ function setProfileCookie(context, profileId) {
7482
+ setCookie(context, profileCookieName, String(profileId), {
7483
+ httpOnly: true,
7484
+ maxAge: oneYearSeconds,
7485
+ path: "/",
7486
+ sameSite: "Lax"
7487
+ });
7488
+ }
7489
+ async function readProfileFromCookie(rawCookieValue) {
7490
+ if (!rawCookieValue) return null;
7491
+ const parsedId = parseInt(rawCookieValue, 10);
7492
+ if (isNaN(parsedId)) return null;
7493
+ const [profile] = await db.select().from(profiles).where(eq(profiles.id, parsedId)).limit(1);
7494
+ return profile ?? null;
7495
+ }
7496
+ async function fallbackProfile() {
7497
+ const [latest] = await db.select().from(profiles).orderBy(desc(profiles.createdAt)).limit(1);
7498
+ if (latest) return latest;
7499
+ const now = /* @__PURE__ */ new Date();
7500
+ const [created] = await db.insert(profiles).values({
7501
+ name: "Default",
7502
+ emoji: "🍿",
7503
+ createdAt: now
7504
+ }).returning();
7505
+ if (!created) throw new Error("Failed to create default profile");
7506
+ return created;
7507
+ }
7508
+ const profileMiddleware = createMiddleware(async (context, next) => {
7509
+ const fromCookie = await readProfileFromCookie(getCookie(context, profileCookieName));
7510
+ const profile = fromCookie ?? await fallbackProfile();
7511
+ if (!fromCookie || fromCookie.id !== profile.id) setProfileCookie(context, profile.id);
7512
+ context.set("profileId", profile.id);
7513
+ await next();
7514
+ });
7515
+ //#endregion
6726
7516
  //#region src/api/routes/episode-progress.ts
6727
7517
  const episodeProgressRoutes = new Hono();
6728
7518
  episodeProgressRoutes.get("/show/:showId", async (context) => {
7519
+ const profileId = context.get("profileId");
6729
7520
  const showId = parseInt(context.req.param("showId"), 10);
6730
7521
  if (isNaN(showId)) return context.json({ error: { message: "Invalid show ID" } }, 400);
6731
7522
  const episodeIds = (await db.select({ id: episodes.id }).from(episodes).where(eq(episodes.showId, showId))).map((episode) => episode.id);
@@ -6735,7 +7526,7 @@ episodeProgressRoutes.get("/show/:showId", async (context) => {
6735
7526
  currentTime: watchProgress.currentTime,
6736
7527
  duration: watchProgress.duration,
6737
7528
  updatedAt: watchProgress.updatedAt
6738
- }).from(watchProgress).where(and(inArray(watchProgress.episodeId, episodeIds), gt(watchProgress.currentTime, 0)));
7529
+ }).from(watchProgress).where(and(eq(watchProgress.profileId, profileId), inArray(watchProgress.episodeId, episodeIds), gt(watchProgress.currentTime, 0)));
6739
7530
  const progressMap = {};
6740
7531
  for (const row of progressRows) if (row.episodeId !== null) progressMap[row.episodeId] = {
6741
7532
  currentTime: row.currentTime,
@@ -6745,9 +7536,10 @@ episodeProgressRoutes.get("/show/:showId", async (context) => {
6745
7536
  return context.json(progressMap);
6746
7537
  });
6747
7538
  episodeProgressRoutes.get("/:episodeId", async (context) => {
7539
+ const profileId = context.get("profileId");
6748
7540
  const episodeId = parseInt(context.req.param("episodeId"), 10);
6749
7541
  if (isNaN(episodeId)) return context.json({ error: { message: "Invalid episode ID" } }, 400);
6750
- const [progress] = await db.select().from(watchProgress).where(eq(watchProgress.episodeId, episodeId)).limit(1);
7542
+ const [progress] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.episodeId, episodeId))).limit(1);
6751
7543
  if (!progress) return context.json({
6752
7544
  currentTime: 0,
6753
7545
  duration: null
@@ -6758,18 +7550,20 @@ episodeProgressRoutes.get("/:episodeId", async (context) => {
6758
7550
  });
6759
7551
  });
6760
7552
  episodeProgressRoutes.put("/:episodeId", async (context) => {
7553
+ const profileId = context.get("profileId");
6761
7554
  const episodeId = parseInt(context.req.param("episodeId"), 10);
6762
7555
  if (isNaN(episodeId)) return context.json({ error: { message: "Invalid episode ID" } }, 400);
6763
7556
  const body = await context.req.json();
6764
7557
  if (typeof body.currentTime !== "number") return context.json({ error: { message: "currentTime is required" } }, 400);
6765
- const [existing] = await db.select().from(watchProgress).where(eq(watchProgress.episodeId, episodeId)).limit(1);
7558
+ const [existing] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.episodeId, episodeId))).limit(1);
6766
7559
  const now = /* @__PURE__ */ new Date();
6767
7560
  if (existing) await db.update(watchProgress).set({
6768
7561
  currentTime: Math.floor(body.currentTime),
6769
7562
  duration: body.duration ? Math.floor(body.duration) : existing.duration,
6770
7563
  updatedAt: now
6771
- }).where(eq(watchProgress.episodeId, episodeId));
7564
+ }).where(eq(watchProgress.id, existing.id));
6772
7565
  else await db.insert(watchProgress).values({
7566
+ profileId,
6773
7567
  episodeId,
6774
7568
  currentTime: Math.floor(body.currentTime),
6775
7569
  duration: body.duration ? Math.floor(body.duration) : null,
@@ -6778,17 +7572,6 @@ episodeProgressRoutes.put("/:episodeId", async (context) => {
6778
7572
  return context.json({ success: true });
6779
7573
  });
6780
7574
  //#endregion
6781
- //#region node_modules/tiny-invariant/dist/esm/tiny-invariant.js
6782
- var isProduction = process.env.NODE_ENV === "production";
6783
- var prefix = "Invariant failed";
6784
- function invariant(condition, message) {
6785
- if (condition) return;
6786
- if (isProduction) throw new Error(prefix);
6787
- var provided = typeof message === "function" ? message() : message;
6788
- var value = provided ? "".concat(prefix, ": ").concat(provided) : prefix;
6789
- throw new Error(value);
6790
- }
6791
- //#endregion
6792
7575
  //#region src/api/routes/episode-stream.ts
6793
7576
  const mimeTypes$1 = {
6794
7577
  ".avi": "video/x-msvideo",
@@ -6853,38 +7636,352 @@ helloRoutes.put("/", (c) => c.json({
6853
7636
  }));
6854
7637
  helloRoutes.get("/:name", (c) => c.json({ message: `Hello, ${c.req.param("name")}!` }));
6855
7638
  //#endregion
6856
- //#region src/api/routes/library.ts
6857
- const libraryRoutes = new Hono();
6858
- libraryRoutes.get("/movies", async (c) => {
6859
- const movies$2 = await db.select().from(movies).orderBy(movies.title);
6860
- return c.json(movies$2);
6861
- });
6862
- libraryRoutes.get("/movies/:id", async (c) => {
6863
- const id = parseInt(c.req.param("id"), 10);
6864
- if (isNaN(id)) return c.json({ error: { message: "Invalid movie ID" } }, 400);
6865
- const movie = await db.select().from(movies).where(eq(movies.id, id)).limit(1);
6866
- if (movie.length === 0) return c.json({ error: { message: "Movie not found" } }, 404);
6867
- return c.json(movie[0]);
6868
- });
7639
+ //#region src/services/filename-parser.ts
7640
+ const subtitleExtensions$1 = {
7641
+ ".ass": "ass",
7642
+ ".srt": "srt",
7643
+ ".vtt": "vtt"
7644
+ };
7645
+ const threeLetterToTwoLetter = {
7646
+ ara: "ar",
7647
+ ces: "cs",
7648
+ cze: "cs",
7649
+ dan: "da",
7650
+ deu: "de",
7651
+ dut: "nl",
7652
+ ell: "el",
7653
+ eng: "en",
7654
+ fin: "fi",
7655
+ fra: "fr",
7656
+ fre: "fr",
7657
+ ger: "de",
7658
+ gre: "el",
7659
+ heb: "he",
7660
+ hin: "hi",
7661
+ hun: "hu",
7662
+ ind: "id",
7663
+ ita: "it",
7664
+ jpn: "ja",
7665
+ kor: "ko",
7666
+ nld: "nl",
7667
+ nor: "no",
7668
+ pol: "pl",
7669
+ por: "pt",
7670
+ ron: "ro",
7671
+ rum: "ro",
7672
+ rus: "ru",
7673
+ spa: "es",
7674
+ swe: "sv",
7675
+ tha: "th",
7676
+ tur: "tr",
7677
+ ukr: "uk",
7678
+ vie: "vi",
7679
+ zho: "zh",
7680
+ chi: "zh"
7681
+ };
7682
+ const fullNameToCode = {
7683
+ arabic: "ar",
7684
+ chinese: "zh",
7685
+ czech: "cs",
7686
+ danish: "da",
7687
+ dutch: "nl",
7688
+ english: "en",
7689
+ finnish: "fi",
7690
+ french: "fr",
7691
+ german: "de",
7692
+ greek: "el",
7693
+ hebrew: "he",
7694
+ hindi: "hi",
7695
+ hungarian: "hu",
7696
+ indonesian: "id",
7697
+ italian: "it",
7698
+ japanese: "ja",
7699
+ korean: "ko",
7700
+ norwegian: "no",
7701
+ polish: "pl",
7702
+ portuguese: "pt",
7703
+ romanian: "ro",
7704
+ russian: "ru",
7705
+ spanish: "es",
7706
+ swedish: "sv",
7707
+ thai: "th",
7708
+ turkish: "tr",
7709
+ ukrainian: "uk",
7710
+ vietnamese: "vi"
7711
+ };
7712
+ const subtitleModifiers = new Set([
7713
+ "cc",
7714
+ "forced",
7715
+ "hoh",
7716
+ "sdh"
7717
+ ]);
7718
+ /**
7719
+ * Parse a movie filename to extract title and year.
7720
+ * The year marks the boundary between the title and technical info.
7721
+ *
7722
+ * Examples:
7723
+ * - Jurassic.Park.1993.720p.BrRip.264.YIFY.mp4 -> { title: "Jurassic Park", year: 1993 }
7724
+ * - The.Social.Network.(2010).1080p.BrRip.x264.mp4 -> { title: "The Social Network", year: 2010 }
7725
+ */
7726
+ function parseFilename(fileName) {
7727
+ const name = fileName.replace(/\.[^.]+$/, "");
7728
+ const bracketYearMatch = name.match(/[([]((?:19|20)\d{2})[)\]]/);
7729
+ if (bracketYearMatch?.[1]) {
7730
+ const year = parseInt(bracketYearMatch[1], 10);
7731
+ const yearIndex = bracketYearMatch.index ?? 0;
7732
+ let title = name.substring(0, yearIndex);
7733
+ title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
7734
+ title = title.replace(/[-–—]+$/, "").trim();
7735
+ return {
7736
+ title,
7737
+ year
7738
+ };
7739
+ }
7740
+ const yearPattern = /[.\s]((?:19|20)\d{2})[.\s]/g;
7741
+ let match;
7742
+ let bestMatch = null;
7743
+ while ((match = yearPattern.exec(name)) !== null) {
7744
+ const yearStr = match[1];
7745
+ if (!yearStr) continue;
7746
+ const year = parseInt(yearStr, 10);
7747
+ const afterYear = name.substring(match.index + match[0].length);
7748
+ const looksLikeTechInfo = /^(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|HDTV|DVDRip|x264|x265|H\.?264)/i.test(afterYear);
7749
+ if (looksLikeTechInfo || !bestMatch) {
7750
+ bestMatch = {
7751
+ year,
7752
+ index: match.index
7753
+ };
7754
+ if (looksLikeTechInfo) break;
7755
+ }
7756
+ }
7757
+ if (bestMatch) {
7758
+ let title = name.substring(0, bestMatch.index);
7759
+ title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
7760
+ title = title.replace(/[-–—]+$/, "").trim();
7761
+ return {
7762
+ title,
7763
+ year: bestMatch.year
7764
+ };
7765
+ }
7766
+ return {
7767
+ title: name.replace(/[._]/g, " ").replace(/\s+/g, " ").trim(),
7768
+ year: null
7769
+ };
7770
+ }
7771
+ /**
7772
+ * Extract year from a filename if present
7773
+ */
7774
+ function extractYear(fileName) {
7775
+ return parseFilename(fileName).year;
7776
+ }
7777
+ /**
7778
+ * Clean a movie filename to extract a readable title
7779
+ */
7780
+ function cleanTitle(fileName) {
7781
+ return parseFilename(fileName).title;
7782
+ }
7783
+ function normalizeLanguageToken(rawToken) {
7784
+ const token = rawToken.toLowerCase();
7785
+ if (subtitleModifiers.has(token)) return null;
7786
+ if (fullNameToCode[token]) return fullNameToCode[token];
7787
+ if (threeLetterToTwoLetter[token]) return threeLetterToTwoLetter[token];
7788
+ if (/^[a-z]{2}$/.test(token)) return token;
7789
+ if (/^[a-z]{3}$/.test(token)) return token;
7790
+ return null;
7791
+ }
7792
+ /**
7793
+ * Parse a sidecar subtitle filename. Returns the base name (the video filename
7794
+ * without the language suffix or extension), the detected language as a
7795
+ * lowercase code (ISO-639-1 when known, otherwise the raw token, or 'und' when
7796
+ * the filename has no language hint), and the subtitle format.
7797
+ *
7798
+ * Returns null when the file isn't a recognized subtitle format.
7799
+ *
7800
+ * Supported patterns:
7801
+ * - Movie.srt → language 'und'
7802
+ * - Movie.en.srt / Movie.eng.srt → 'en'
7803
+ * - Movie.English.srt → 'en'
7804
+ * - Movie.en.forced.srt → 'en' (forced/sdh/cc/hoh modifiers ignored)
7805
+ * - Show.S01E02.en.srt → baseName 'Show.S01E02', language 'en'
7806
+ */
7807
+ function parseSubtitleFilename(fileName) {
7808
+ const lastDot = fileName.lastIndexOf(".");
7809
+ if (lastDot < 0) return null;
7810
+ const format = subtitleExtensions$1[fileName.substring(lastDot).toLowerCase()];
7811
+ if (!format) return null;
7812
+ const tokens = fileName.substring(0, lastDot).split(".");
7813
+ let language = "und";
7814
+ let baseEndIndex = tokens.length;
7815
+ for (let cursor = tokens.length - 1; cursor >= 1; cursor--) {
7816
+ const token = tokens[cursor];
7817
+ if (!token) continue;
7818
+ const lowered = token.toLowerCase();
7819
+ if (subtitleModifiers.has(lowered)) {
7820
+ baseEndIndex = cursor;
7821
+ continue;
7822
+ }
7823
+ const normalized = normalizeLanguageToken(token);
7824
+ if (normalized) {
7825
+ language = normalized;
7826
+ baseEndIndex = cursor;
7827
+ }
7828
+ break;
7829
+ }
7830
+ return {
7831
+ baseName: tokens.slice(0, baseEndIndex).join("."),
7832
+ format,
7833
+ language
7834
+ };
7835
+ }
6869
7836
  //#endregion
6870
- //#region src/api/routes/progress.ts
6871
- const progressRoutes = new Hono();
6872
- progressRoutes.get("/continue-watching", async (context) => {
6873
- const inProgressFilter = and(gt(watchProgress.currentTime, 0), sql`(${watchProgress.duration} IS NULL OR ${watchProgress.currentTime} < ${watchProgress.duration} * 0.9)`);
6874
- const movieResults = await db.select({
6875
- currentTime: watchProgress.currentTime,
6876
- duration: watchProgress.duration,
6877
- movie: movies,
6878
- updatedAt: watchProgress.updatedAt
6879
- }).from(watchProgress).innerJoin(movies, eq(watchProgress.movieId, movies.id)).where(inProgressFilter);
6880
- const episodeResults = await db.select({
6881
- currentTime: watchProgress.currentTime,
6882
- duration: watchProgress.duration,
6883
- episode: episodes,
6884
- show: shows,
6885
- updatedAt: watchProgress.updatedAt
6886
- }).from(watchProgress).innerJoin(episodes, eq(watchProgress.episodeId, episodes.id)).innerJoin(shows, eq(episodes.showId, shows.id)).where(inProgressFilter);
6887
- const movies$1 = movieResults.map((result) => ({
7837
+ //#region src/services/media-extensions.ts
7838
+ const videoExtensions$1 = new Set([
7839
+ ".mp4",
7840
+ ".mkv",
7841
+ ".avi",
7842
+ ".mov",
7843
+ ".wmv",
7844
+ ".m4v",
7845
+ ".webm",
7846
+ ".flv",
7847
+ ".mpeg",
7848
+ ".mpg"
7849
+ ]);
7850
+ const subtitleExtensions = new Set([
7851
+ ".ass",
7852
+ ".srt",
7853
+ ".vtt"
7854
+ ]);
7855
+ //#endregion
7856
+ //#region src/services/subtitles.ts
7857
+ const languageNames = {
7858
+ ar: "Arabic",
7859
+ cs: "Czech",
7860
+ da: "Danish",
7861
+ de: "German",
7862
+ el: "Greek",
7863
+ en: "English",
7864
+ es: "Spanish",
7865
+ fi: "Finnish",
7866
+ fr: "French",
7867
+ he: "Hebrew",
7868
+ hi: "Hindi",
7869
+ hu: "Hungarian",
7870
+ id: "Indonesian",
7871
+ it: "Italian",
7872
+ ja: "Japanese",
7873
+ ko: "Korean",
7874
+ nl: "Dutch",
7875
+ no: "Norwegian",
7876
+ pl: "Polish",
7877
+ pt: "Portuguese",
7878
+ ro: "Romanian",
7879
+ ru: "Russian",
7880
+ sv: "Swedish",
7881
+ th: "Thai",
7882
+ tr: "Turkish",
7883
+ uk: "Ukrainian",
7884
+ vi: "Vietnamese",
7885
+ zh: "Chinese"
7886
+ };
7887
+ /**
7888
+ * Map a language code to a human-readable label. Returns "Unknown" for the
7889
+ * undetermined sentinel and the raw code for languages not in our table.
7890
+ */
7891
+ function languageDisplayName(code) {
7892
+ if (code === "und") return "Unknown";
7893
+ return languageNames[code] ?? code;
7894
+ }
7895
+ function stripVideoExtension(fileName) {
7896
+ const extension = extname(fileName);
7897
+ if (!extension) return fileName;
7898
+ return fileName.substring(0, fileName.length - extension.length);
7899
+ }
7900
+ /**
7901
+ * Match sidecar subtitles to a video file. Siblings must already be filtered
7902
+ * to subtitle entries (see `readSubtitleSiblings`). A subtitle matches when
7903
+ * its base name (after stripping language suffix and modifiers) equals the
7904
+ * video filename without extension.
7905
+ *
7906
+ * Results are returned in stable order (by file path) so callers can compare
7907
+ * against existing rows without spurious diffs.
7908
+ */
7909
+ function discoverSubtitlesForVideo(videoFilePath, siblingSubtitleFiles) {
7910
+ const directory = dirname(videoFilePath);
7911
+ const videoStem = stripVideoExtension(basename(videoFilePath));
7912
+ const matches = [];
7913
+ for (const sibling of siblingSubtitleFiles) {
7914
+ const parsed = parseSubtitleFilename(sibling);
7915
+ if (!parsed) continue;
7916
+ if (parsed.baseName !== videoStem) continue;
7917
+ matches.push({
7918
+ filePath: join(directory, sibling),
7919
+ format: parsed.format,
7920
+ language: parsed.language
7921
+ });
7922
+ }
7923
+ matches.sort((a, b) => a.filePath.localeCompare(b.filePath));
7924
+ return matches;
7925
+ }
7926
+ /**
7927
+ * Convert SubRip (.srt) text into WebVTT (.vtt) text. Browsers don't natively
7928
+ * support .srt, so the streaming endpoint converts on the fly.
7929
+ *
7930
+ * Handles UTF-8 BOM, CRLF line endings, and the comma-vs-period decimal in
7931
+ * timestamps. Cue identifiers are preserved (they're optional in WebVTT but
7932
+ * harmless when present).
7933
+ */
7934
+ function convertSrtToVtt(srt) {
7935
+ let body = srt;
7936
+ if (body.charCodeAt(0) === 65279) body = body.substring(1);
7937
+ body = body.replace(/\r\n?/g, "\n");
7938
+ body = body.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2 --> $3.$4");
7939
+ return `WEBVTT\n\n${body.trimStart()}`;
7940
+ }
7941
+ //#endregion
7942
+ //#region src/api/routes/library.ts
7943
+ const libraryRoutes = new Hono();
7944
+ libraryRoutes.get("/movies", async (c) => {
7945
+ const movies$3 = await db.select().from(movies).orderBy(movies.title);
7946
+ return c.json(movies$3);
7947
+ });
7948
+ libraryRoutes.get("/movies/:id", async (c) => {
7949
+ const id = parseInt(c.req.param("id"), 10);
7950
+ if (isNaN(id)) return c.json({ error: { message: "Invalid movie ID" } }, 400);
7951
+ const [movie] = await db.select().from(movies).where(eq(movies.id, id)).limit(1);
7952
+ if (!movie) return c.json({ error: { message: "Movie not found" } }, 404);
7953
+ const subtitles$2 = (await db.select().from(subtitles).where(eq(subtitles.movieId, id))).map((row) => ({
7954
+ id: row.id,
7955
+ displayLanguage: languageDisplayName(row.language),
7956
+ format: row.format,
7957
+ isSupported: row.format !== "ass",
7958
+ language: row.language
7959
+ }));
7960
+ return c.json({
7961
+ ...movie,
7962
+ subtitles: subtitles$2
7963
+ });
7964
+ });
7965
+ //#endregion
7966
+ //#region src/api/routes/progress.ts
7967
+ const progressRoutes = new Hono();
7968
+ progressRoutes.get("/continue-watching", async (context) => {
7969
+ const profileId = context.get("profileId");
7970
+ const inProgressFilter = and(eq(watchProgress.profileId, profileId), gt(watchProgress.currentTime, 0), sql`(${watchProgress.duration} IS NULL OR ${watchProgress.currentTime} < ${watchProgress.duration} * 0.9)`);
7971
+ const movieResults = await db.select({
7972
+ currentTime: watchProgress.currentTime,
7973
+ duration: watchProgress.duration,
7974
+ movie: movies,
7975
+ updatedAt: watchProgress.updatedAt
7976
+ }).from(watchProgress).innerJoin(movies, eq(watchProgress.movieId, movies.id)).where(inProgressFilter);
7977
+ const episodeResults = await db.select({
7978
+ currentTime: watchProgress.currentTime,
7979
+ duration: watchProgress.duration,
7980
+ episode: episodes,
7981
+ show: shows,
7982
+ updatedAt: watchProgress.updatedAt
7983
+ }).from(watchProgress).innerJoin(episodes, eq(watchProgress.episodeId, episodes.id)).innerJoin(shows, eq(episodes.showId, shows.id)).where(inProgressFilter);
7984
+ const movies$2 = movieResults.map((result) => ({
6888
7985
  ...result,
6889
7986
  type: "movie"
6890
7987
  }));
@@ -6892,13 +7989,14 @@ progressRoutes.get("/continue-watching", async (context) => {
6892
7989
  ...result,
6893
7990
  type: "episode"
6894
7991
  }));
6895
- const combined = [...movies$1, ...episodes$2].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).slice(0, 20);
7992
+ const combined = [...movies$2, ...episodes$2].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).slice(0, 20);
6896
7993
  return context.json(combined);
6897
7994
  });
6898
7995
  progressRoutes.get("/:movieId", async (c) => {
7996
+ const profileId = c.get("profileId");
6899
7997
  const movieId = parseInt(c.req.param("movieId"), 10);
6900
7998
  if (isNaN(movieId)) return c.json({ error: { message: "Invalid movie ID" } }, 400);
6901
- const [progress] = await db.select().from(watchProgress).where(eq(watchProgress.movieId, movieId)).limit(1);
7999
+ const [progress] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.movieId, movieId))).limit(1);
6902
8000
  if (!progress) return c.json({
6903
8001
  currentTime: 0,
6904
8002
  duration: null
@@ -6909,18 +8007,20 @@ progressRoutes.get("/:movieId", async (c) => {
6909
8007
  });
6910
8008
  });
6911
8009
  progressRoutes.put("/:movieId", async (c) => {
8010
+ const profileId = c.get("profileId");
6912
8011
  const movieId = parseInt(c.req.param("movieId"), 10);
6913
8012
  if (isNaN(movieId)) return c.json({ error: { message: "Invalid movie ID" } }, 400);
6914
8013
  const body = await c.req.json();
6915
8014
  if (typeof body.currentTime !== "number") return c.json({ error: { message: "currentTime is required" } }, 400);
6916
- const [existing] = await db.select().from(watchProgress).where(eq(watchProgress.movieId, movieId)).limit(1);
8015
+ const [existing] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.movieId, movieId))).limit(1);
6917
8016
  const now = /* @__PURE__ */ new Date();
6918
8017
  if (existing) await db.update(watchProgress).set({
6919
8018
  currentTime: Math.floor(body.currentTime),
6920
8019
  duration: body.duration ? Math.floor(body.duration) : existing.duration,
6921
8020
  updatedAt: now
6922
- }).where(eq(watchProgress.movieId, movieId));
8021
+ }).where(eq(watchProgress.id, existing.id));
6923
8022
  else await db.insert(watchProgress).values({
8023
+ profileId,
6924
8024
  movieId,
6925
8025
  currentTime: Math.floor(body.currentTime),
6926
8026
  duration: body.duration ? Math.floor(body.duration) : null,
@@ -7060,15 +8160,551 @@ var streamSSE = (c, cb, onError) => {
7060
8160
  return c.newResponse(stream.responseReadable);
7061
8161
  };
7062
8162
  //#endregion
8163
+ //#region node_modules/chalk/source/vendor/ansi-styles/index.js
8164
+ const ANSI_BACKGROUND_OFFSET = 10;
8165
+ const wrapAnsi16 = (offset = 0) => (code) => `\u001B[${code + offset}m`;
8166
+ const wrapAnsi256 = (offset = 0) => (code) => `\u001B[${38 + offset};5;${code}m`;
8167
+ const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`;
8168
+ const styles$1 = {
8169
+ modifier: {
8170
+ reset: [0, 0],
8171
+ bold: [1, 22],
8172
+ dim: [2, 22],
8173
+ italic: [3, 23],
8174
+ underline: [4, 24],
8175
+ overline: [53, 55],
8176
+ inverse: [7, 27],
8177
+ hidden: [8, 28],
8178
+ strikethrough: [9, 29]
8179
+ },
8180
+ color: {
8181
+ black: [30, 39],
8182
+ red: [31, 39],
8183
+ green: [32, 39],
8184
+ yellow: [33, 39],
8185
+ blue: [34, 39],
8186
+ magenta: [35, 39],
8187
+ cyan: [36, 39],
8188
+ white: [37, 39],
8189
+ blackBright: [90, 39],
8190
+ gray: [90, 39],
8191
+ grey: [90, 39],
8192
+ redBright: [91, 39],
8193
+ greenBright: [92, 39],
8194
+ yellowBright: [93, 39],
8195
+ blueBright: [94, 39],
8196
+ magentaBright: [95, 39],
8197
+ cyanBright: [96, 39],
8198
+ whiteBright: [97, 39]
8199
+ },
8200
+ bgColor: {
8201
+ bgBlack: [40, 49],
8202
+ bgRed: [41, 49],
8203
+ bgGreen: [42, 49],
8204
+ bgYellow: [43, 49],
8205
+ bgBlue: [44, 49],
8206
+ bgMagenta: [45, 49],
8207
+ bgCyan: [46, 49],
8208
+ bgWhite: [47, 49],
8209
+ bgBlackBright: [100, 49],
8210
+ bgGray: [100, 49],
8211
+ bgGrey: [100, 49],
8212
+ bgRedBright: [101, 49],
8213
+ bgGreenBright: [102, 49],
8214
+ bgYellowBright: [103, 49],
8215
+ bgBlueBright: [104, 49],
8216
+ bgMagentaBright: [105, 49],
8217
+ bgCyanBright: [106, 49],
8218
+ bgWhiteBright: [107, 49]
8219
+ }
8220
+ };
8221
+ Object.keys(styles$1.modifier);
8222
+ const foregroundColorNames = Object.keys(styles$1.color);
8223
+ const backgroundColorNames = Object.keys(styles$1.bgColor);
8224
+ [...foregroundColorNames, ...backgroundColorNames];
8225
+ function assembleStyles() {
8226
+ const codes = /* @__PURE__ */ new Map();
8227
+ for (const [groupName, group] of Object.entries(styles$1)) {
8228
+ for (const [styleName, style] of Object.entries(group)) {
8229
+ styles$1[styleName] = {
8230
+ open: `\u001B[${style[0]}m`,
8231
+ close: `\u001B[${style[1]}m`
8232
+ };
8233
+ group[styleName] = styles$1[styleName];
8234
+ codes.set(style[0], style[1]);
8235
+ }
8236
+ Object.defineProperty(styles$1, groupName, {
8237
+ value: group,
8238
+ enumerable: false
8239
+ });
8240
+ }
8241
+ Object.defineProperty(styles$1, "codes", {
8242
+ value: codes,
8243
+ enumerable: false
8244
+ });
8245
+ styles$1.color.close = "\x1B[39m";
8246
+ styles$1.bgColor.close = "\x1B[49m";
8247
+ styles$1.color.ansi = wrapAnsi16();
8248
+ styles$1.color.ansi256 = wrapAnsi256();
8249
+ styles$1.color.ansi16m = wrapAnsi16m();
8250
+ styles$1.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET);
8251
+ styles$1.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET);
8252
+ styles$1.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
8253
+ Object.defineProperties(styles$1, {
8254
+ rgbToAnsi256: {
8255
+ value(red, green, blue) {
8256
+ if (red === green && green === blue) {
8257
+ if (red < 8) return 16;
8258
+ if (red > 248) return 231;
8259
+ return Math.round((red - 8) / 247 * 24) + 232;
8260
+ }
8261
+ return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5);
8262
+ },
8263
+ enumerable: false
8264
+ },
8265
+ hexToRgb: {
8266
+ value(hex) {
8267
+ const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
8268
+ if (!matches) return [
8269
+ 0,
8270
+ 0,
8271
+ 0
8272
+ ];
8273
+ let [colorString] = matches;
8274
+ if (colorString.length === 3) colorString = [...colorString].map((character) => character + character).join("");
8275
+ const integer = Number.parseInt(colorString, 16);
8276
+ return [
8277
+ integer >> 16 & 255,
8278
+ integer >> 8 & 255,
8279
+ integer & 255
8280
+ ];
8281
+ },
8282
+ enumerable: false
8283
+ },
8284
+ hexToAnsi256: {
8285
+ value: (hex) => styles$1.rgbToAnsi256(...styles$1.hexToRgb(hex)),
8286
+ enumerable: false
8287
+ },
8288
+ ansi256ToAnsi: {
8289
+ value(code) {
8290
+ if (code < 8) return 30 + code;
8291
+ if (code < 16) return 90 + (code - 8);
8292
+ let red;
8293
+ let green;
8294
+ let blue;
8295
+ if (code >= 232) {
8296
+ red = ((code - 232) * 10 + 8) / 255;
8297
+ green = red;
8298
+ blue = red;
8299
+ } else {
8300
+ code -= 16;
8301
+ const remainder = code % 36;
8302
+ red = Math.floor(code / 36) / 5;
8303
+ green = Math.floor(remainder / 6) / 5;
8304
+ blue = remainder % 6 / 5;
8305
+ }
8306
+ const value = Math.max(red, green, blue) * 2;
8307
+ if (value === 0) return 30;
8308
+ let result = 30 + (Math.round(blue) << 2 | Math.round(green) << 1 | Math.round(red));
8309
+ if (value === 2) result += 60;
8310
+ return result;
8311
+ },
8312
+ enumerable: false
8313
+ },
8314
+ rgbToAnsi: {
8315
+ value: (red, green, blue) => styles$1.ansi256ToAnsi(styles$1.rgbToAnsi256(red, green, blue)),
8316
+ enumerable: false
8317
+ },
8318
+ hexToAnsi: {
8319
+ value: (hex) => styles$1.ansi256ToAnsi(styles$1.hexToAnsi256(hex)),
8320
+ enumerable: false
8321
+ }
8322
+ });
8323
+ return styles$1;
8324
+ }
8325
+ const ansiStyles = assembleStyles();
8326
+ //#endregion
8327
+ //#region node_modules/chalk/source/vendor/supports-color/index.js
8328
+ function hasFlag(flag, argv = globalThis.Deno ? globalThis.Deno.args : process$1.argv) {
8329
+ const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
8330
+ const position = argv.indexOf(prefix + flag);
8331
+ const terminatorPosition = argv.indexOf("--");
8332
+ return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
8333
+ }
8334
+ const { env } = process$1;
8335
+ let flagForceColor;
8336
+ if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) flagForceColor = 0;
8337
+ else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) flagForceColor = 1;
8338
+ function envForceColor() {
8339
+ if ("FORCE_COLOR" in env) {
8340
+ if (env.FORCE_COLOR === "true") return 1;
8341
+ if (env.FORCE_COLOR === "false") return 0;
8342
+ return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3);
8343
+ }
8344
+ }
8345
+ function translateLevel(level) {
8346
+ if (level === 0) return false;
8347
+ return {
8348
+ level,
8349
+ hasBasic: true,
8350
+ has256: level >= 2,
8351
+ has16m: level >= 3
8352
+ };
8353
+ }
8354
+ function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
8355
+ const noFlagForceColor = envForceColor();
8356
+ if (noFlagForceColor !== void 0) flagForceColor = noFlagForceColor;
8357
+ const forceColor = sniffFlags ? flagForceColor : noFlagForceColor;
8358
+ if (forceColor === 0) return 0;
8359
+ if (sniffFlags) {
8360
+ if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) return 3;
8361
+ if (hasFlag("color=256")) return 2;
8362
+ }
8363
+ if ("TF_BUILD" in env && "AGENT_NAME" in env) return 1;
8364
+ if (haveStream && !streamIsTTY && forceColor === void 0) return 0;
8365
+ const min = forceColor || 0;
8366
+ if (env.TERM === "dumb") return min;
8367
+ if (process$1.platform === "win32") {
8368
+ const osRelease = os$1.release().split(".");
8369
+ if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) return Number(osRelease[2]) >= 14931 ? 3 : 2;
8370
+ return 1;
8371
+ }
8372
+ if ("CI" in env) {
8373
+ if ([
8374
+ "GITHUB_ACTIONS",
8375
+ "GITEA_ACTIONS",
8376
+ "CIRCLECI"
8377
+ ].some((key) => key in env)) return 3;
8378
+ if ([
8379
+ "TRAVIS",
8380
+ "APPVEYOR",
8381
+ "GITLAB_CI",
8382
+ "BUILDKITE",
8383
+ "DRONE"
8384
+ ].some((sign) => sign in env) || env.CI_NAME === "codeship") return 1;
8385
+ return min;
8386
+ }
8387
+ if ("TEAMCITY_VERSION" in env) return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
8388
+ if (env.COLORTERM === "truecolor") return 3;
8389
+ if (env.TERM === "xterm-kitty") return 3;
8390
+ if (env.TERM === "xterm-ghostty") return 3;
8391
+ if (env.TERM === "wezterm") return 3;
8392
+ if ("TERM_PROGRAM" in env) {
8393
+ const version = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
8394
+ switch (env.TERM_PROGRAM) {
8395
+ case "iTerm.app": return version >= 3 ? 3 : 2;
8396
+ case "Apple_Terminal": return 2;
8397
+ }
8398
+ }
8399
+ if (/-256(color)?$/i.test(env.TERM)) return 2;
8400
+ if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) return 1;
8401
+ if ("COLORTERM" in env) return 1;
8402
+ return min;
8403
+ }
8404
+ function createSupportsColor(stream, options = {}) {
8405
+ return translateLevel(_supportsColor(stream, {
8406
+ streamIsTTY: stream && stream.isTTY,
8407
+ ...options
8408
+ }));
8409
+ }
8410
+ const supportsColor = {
8411
+ stdout: createSupportsColor({ isTTY: tty.isatty(1) }),
8412
+ stderr: createSupportsColor({ isTTY: tty.isatty(2) })
8413
+ };
8414
+ //#endregion
8415
+ //#region node_modules/chalk/source/utilities.js
8416
+ function stringReplaceAll(string, substring, replacer) {
8417
+ let index = string.indexOf(substring);
8418
+ if (index === -1) return string;
8419
+ const substringLength = substring.length;
8420
+ let endIndex = 0;
8421
+ let returnValue = "";
8422
+ do {
8423
+ returnValue += string.slice(endIndex, index) + substring + replacer;
8424
+ endIndex = index + substringLength;
8425
+ index = string.indexOf(substring, endIndex);
8426
+ } while (index !== -1);
8427
+ returnValue += string.slice(endIndex);
8428
+ return returnValue;
8429
+ }
8430
+ function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
8431
+ let endIndex = 0;
8432
+ let returnValue = "";
8433
+ do {
8434
+ const gotCR = string[index - 1] === "\r";
8435
+ returnValue += string.slice(endIndex, gotCR ? index - 1 : index) + prefix + (gotCR ? "\r\n" : "\n") + postfix;
8436
+ endIndex = index + 1;
8437
+ index = string.indexOf("\n", endIndex);
8438
+ } while (index !== -1);
8439
+ returnValue += string.slice(endIndex);
8440
+ return returnValue;
8441
+ }
8442
+ //#endregion
8443
+ //#region node_modules/chalk/source/index.js
8444
+ const { stdout: stdoutColor, stderr: stderrColor } = supportsColor;
8445
+ const GENERATOR = Symbol("GENERATOR");
8446
+ const STYLER = Symbol("STYLER");
8447
+ const IS_EMPTY = Symbol("IS_EMPTY");
8448
+ const levelMapping = [
8449
+ "ansi",
8450
+ "ansi",
8451
+ "ansi256",
8452
+ "ansi16m"
8453
+ ];
8454
+ const styles = Object.create(null);
8455
+ const applyOptions = (object, options = {}) => {
8456
+ if (options.level && !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)) throw new Error("The `level` option should be an integer from 0 to 3");
8457
+ const colorLevel = stdoutColor ? stdoutColor.level : 0;
8458
+ object.level = options.level === void 0 ? colorLevel : options.level;
8459
+ };
8460
+ const chalkFactory = (options) => {
8461
+ const chalk = (...strings) => strings.join(" ");
8462
+ applyOptions(chalk, options);
8463
+ Object.setPrototypeOf(chalk, createChalk.prototype);
8464
+ return chalk;
8465
+ };
8466
+ function createChalk(options) {
8467
+ return chalkFactory(options);
8468
+ }
8469
+ Object.setPrototypeOf(createChalk.prototype, Function.prototype);
8470
+ for (const [styleName, style] of Object.entries(ansiStyles)) styles[styleName] = { get() {
8471
+ const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]);
8472
+ Object.defineProperty(this, styleName, { value: builder });
8473
+ return builder;
8474
+ } };
8475
+ styles.visible = { get() {
8476
+ const builder = createBuilder(this, this[STYLER], true);
8477
+ Object.defineProperty(this, "visible", { value: builder });
8478
+ return builder;
8479
+ } };
8480
+ const getModelAnsi = (model, level, type, ...arguments_) => {
8481
+ if (model === "rgb") {
8482
+ if (level === "ansi16m") return ansiStyles[type].ansi16m(...arguments_);
8483
+ if (level === "ansi256") return ansiStyles[type].ansi256(ansiStyles.rgbToAnsi256(...arguments_));
8484
+ return ansiStyles[type].ansi(ansiStyles.rgbToAnsi(...arguments_));
8485
+ }
8486
+ if (model === "hex") return getModelAnsi("rgb", level, type, ...ansiStyles.hexToRgb(...arguments_));
8487
+ return ansiStyles[type][model](...arguments_);
8488
+ };
8489
+ for (const model of [
8490
+ "rgb",
8491
+ "hex",
8492
+ "ansi256"
8493
+ ]) {
8494
+ styles[model] = { get() {
8495
+ const { level } = this;
8496
+ return function(...arguments_) {
8497
+ const styler = createStyler(getModelAnsi(model, levelMapping[level], "color", ...arguments_), ansiStyles.color.close, this[STYLER]);
8498
+ return createBuilder(this, styler, this[IS_EMPTY]);
8499
+ };
8500
+ } };
8501
+ const bgModel = "bg" + model[0].toUpperCase() + model.slice(1);
8502
+ styles[bgModel] = { get() {
8503
+ const { level } = this;
8504
+ return function(...arguments_) {
8505
+ const styler = createStyler(getModelAnsi(model, levelMapping[level], "bgColor", ...arguments_), ansiStyles.bgColor.close, this[STYLER]);
8506
+ return createBuilder(this, styler, this[IS_EMPTY]);
8507
+ };
8508
+ } };
8509
+ }
8510
+ const proto = Object.defineProperties(() => {}, {
8511
+ ...styles,
8512
+ level: {
8513
+ enumerable: true,
8514
+ get() {
8515
+ return this[GENERATOR].level;
8516
+ },
8517
+ set(level) {
8518
+ this[GENERATOR].level = level;
8519
+ }
8520
+ }
8521
+ });
8522
+ const createStyler = (open, close, parent) => {
8523
+ let openAll;
8524
+ let closeAll;
8525
+ if (parent === void 0) {
8526
+ openAll = open;
8527
+ closeAll = close;
8528
+ } else {
8529
+ openAll = parent.openAll + open;
8530
+ closeAll = close + parent.closeAll;
8531
+ }
8532
+ return {
8533
+ open,
8534
+ close,
8535
+ openAll,
8536
+ closeAll,
8537
+ parent
8538
+ };
8539
+ };
8540
+ const createBuilder = (self, _styler, _isEmpty) => {
8541
+ const builder = (...arguments_) => applyStyle(builder, arguments_.length === 1 ? "" + arguments_[0] : arguments_.join(" "));
8542
+ Object.setPrototypeOf(builder, proto);
8543
+ builder[GENERATOR] = self;
8544
+ builder[STYLER] = _styler;
8545
+ builder[IS_EMPTY] = _isEmpty;
8546
+ return builder;
8547
+ };
8548
+ const applyStyle = (self, string) => {
8549
+ if (self.level <= 0 || !string) return self[IS_EMPTY] ? "" : string;
8550
+ let styler = self[STYLER];
8551
+ if (styler === void 0) return string;
8552
+ const { openAll, closeAll } = styler;
8553
+ if (string.includes("\x1B")) while (styler !== void 0) {
8554
+ string = stringReplaceAll(string, styler.close, styler.open);
8555
+ styler = styler.parent;
8556
+ }
8557
+ const lfIndex = string.indexOf("\n");
8558
+ if (lfIndex !== -1) string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
8559
+ return openAll + string + closeAll;
8560
+ };
8561
+ Object.defineProperties(createChalk.prototype, styles);
8562
+ const chalk = createChalk();
8563
+ createChalk({ level: stderrColor ? stderrColor.level : 0 });
8564
+ //#endregion
8565
+ //#region node_modules/tiny-typescript-logger/dist/index.js
8566
+ var logLevelToName = {
8567
+ [10]: "trace",
8568
+ [20]: "debug",
8569
+ [30]: "info",
8570
+ [40]: "warn",
8571
+ [50]: "error",
8572
+ [60]: "fatal"
8573
+ };
8574
+ var logLevelToColor = {
8575
+ [10]: "#ECD5E3",
8576
+ [20]: "#ABDEE6",
8577
+ [30]: "#97C1A9",
8578
+ [40]: "#FDFD96",
8579
+ [50]: "#FFB6B6",
8580
+ [60]: "#FF6961"
8581
+ };
8582
+ var Logger = class {
8583
+ debug(...items) {
8584
+ this.log(20, ...items);
8585
+ }
8586
+ error(...items) {
8587
+ this.log(50, ...items);
8588
+ }
8589
+ fatal(...items) {
8590
+ this.log(60, ...items);
8591
+ }
8592
+ info(...items) {
8593
+ this.log(30, ...items);
8594
+ }
8595
+ log(level, ...items) {
8596
+ if (items.length === 0) return;
8597
+ const message = this.isString(items[0]) ? items.shift() : "";
8598
+ const formattedMessage = this.formatMessage(level, message);
8599
+ console.log(formattedMessage, ...items);
8600
+ }
8601
+ trace(...items) {
8602
+ this.log(10, ...items);
8603
+ }
8604
+ warn(...items) {
8605
+ this.log(40, ...items);
8606
+ }
8607
+ formatMessage(level, message) {
8608
+ const date = (/* @__PURE__ */ new Date()).toISOString();
8609
+ const formattedDate = chalk.dim(date);
8610
+ const logLevelName = logLevelToName[level] ?? "unknown";
8611
+ const logLevelColor = logLevelToColor[level] ?? "#f1f5f9";
8612
+ return [
8613
+ `[${chalk.hex(logLevelColor)(logLevelName.toUpperCase())}]`,
8614
+ formattedDate,
8615
+ message
8616
+ ].join(" ");
8617
+ }
8618
+ isString(value) {
8619
+ return typeof value === "string";
8620
+ }
8621
+ };
8622
+ var log = new Logger();
8623
+ //#endregion
8624
+ //#region src/services/settings.ts
8625
+ const tmdbApiKeySettingKey = "tmdbApiKey";
8626
+ const scanScheduleSettingKey = "scanSchedule";
8627
+ const scanScheduleValues = [
8628
+ "off",
8629
+ "6h",
8630
+ "12h",
8631
+ "24h"
8632
+ ];
8633
+ function isScanScheduleValue(value) {
8634
+ return scanScheduleValues.includes(value);
8635
+ }
8636
+ /**
8637
+ * Read a setting value from the DB. Returns null when the key is not set.
8638
+ *
8639
+ * Accepts an optional database instance so tests and scripts can pass an
8640
+ * in-memory SQLite instance instead of the singleton.
8641
+ */
8642
+ async function getSetting(key, database = db) {
8643
+ const [row] = await database.select().from(settings).where(eq(settings.key, key)).limit(1);
8644
+ return row?.value ?? null;
8645
+ }
8646
+ /**
8647
+ * Upsert a setting value.
8648
+ *
8649
+ * Uses a single INSERT ... ON CONFLICT so two concurrent callers don't both
8650
+ * read null, both INSERT, and fail with a UNIQUE constraint on the second.
8651
+ */
8652
+ async function setSetting(key, value, database = db) {
8653
+ const now = Date.now();
8654
+ await database.insert(settings).values({
8655
+ key,
8656
+ value,
8657
+ updatedAt: now
8658
+ }).onConflictDoUpdate({
8659
+ target: settings.key,
8660
+ set: {
8661
+ value,
8662
+ updatedAt: now
8663
+ }
8664
+ });
8665
+ }
8666
+ /**
8667
+ * Resolve the TMDB API key using the dual-source strategy:
8668
+ *
8669
+ * 1. Prefer the value stored in the `settings` table.
8670
+ * 2. Fall back to `~/.jukebox/config.json` for backward compatibility on the
8671
+ * first boot after upgrading. Copy the JSON value into the DB once so
8672
+ * subsequent lookups only touch the DB.
8673
+ *
8674
+ * Returns null when neither source has a key configured.
8675
+ */
8676
+ async function getTmdbApiKey(database = db) {
8677
+ const stored = await getSetting(tmdbApiKeySettingKey, database);
8678
+ if (stored !== null && stored.length > 0) return stored;
8679
+ const legacyKey = getConfig()?.tmdbApiKey ?? null;
8680
+ if (legacyKey !== null && legacyKey.length > 0) {
8681
+ await setSetting(tmdbApiKeySettingKey, legacyKey, database);
8682
+ return legacyKey;
8683
+ }
8684
+ return null;
8685
+ }
8686
+ /**
8687
+ * Persist the TMDB API key to the DB. Also mirror the value to the JSON
8688
+ * config file so rollbacks to an older server build keep working.
8689
+ */
8690
+ async function setTmdbApiKey(apiKey, database = db) {
8691
+ await setSetting(tmdbApiKeySettingKey, apiKey, database);
8692
+ try {
8693
+ await saveConfig({ tmdbApiKey: apiKey });
8694
+ } catch (error) {
8695
+ log.warn("Couldn't mirror TMDB key to config.json — rollback safety is degraded. Check permissions on ~/.jukebox/.", error);
8696
+ }
8697
+ }
8698
+ //#endregion
7063
8699
  //#region src/services/tmdb.ts
7064
8700
  const tmdbBaseUrl = "https://api.themoviedb.org/3";
7065
- function getApiKey() {
7066
- const apiKey = getConfig()?.tmdbApiKey ?? process.env.TMDB_API_KEY ?? null;
8701
+ async function getApiKey() {
8702
+ const apiKey = await getTmdbApiKey() ?? process.env.TMDB_API_KEY ?? null;
7067
8703
  if (!apiKey) throw new Error("TMDB API key is not configured");
7068
8704
  return apiKey;
7069
8705
  }
7070
8706
  async function searchMovie(title, year) {
7071
- const apiKey = getApiKey();
8707
+ const apiKey = await getApiKey();
7072
8708
  const params = new URLSearchParams({
7073
8709
  api_key: apiKey,
7074
8710
  query: title
@@ -7079,14 +8715,14 @@ async function searchMovie(title, year) {
7079
8715
  return (await response.json()).results;
7080
8716
  }
7081
8717
  async function getMovieDetails(tmdbId) {
7082
- const apiKey = getApiKey();
8718
+ const apiKey = await getApiKey();
7083
8719
  const params = new URLSearchParams({ api_key: apiKey });
7084
8720
  const response = await fetch(`${tmdbBaseUrl}/movie/${tmdbId}?${params.toString()}`);
7085
8721
  if (!response.ok) throw new Error(`TMDB movie details failed: ${response.statusText}`);
7086
8722
  return await response.json();
7087
8723
  }
7088
8724
  async function getMovieVideos(tmdbId) {
7089
- const apiKey = getApiKey();
8725
+ const apiKey = await getApiKey();
7090
8726
  const params = new URLSearchParams({ api_key: apiKey });
7091
8727
  const response = await fetch(`${tmdbBaseUrl}/movie/${tmdbId}/videos?${params.toString()}`);
7092
8728
  if (!response.ok) throw new Error(`TMDB movie videos failed: ${response.statusText}`);
@@ -7107,7 +8743,7 @@ function getTrailerUrl(videos) {
7107
8743
  }
7108
8744
  async function fetchMovieMetadata(title, year) {
7109
8745
  try {
7110
- getApiKey();
8746
+ await getApiKey();
7111
8747
  } catch {
7112
8748
  return null;
7113
8749
  }
@@ -7130,7 +8766,7 @@ async function fetchMovieMetadata(title, year) {
7130
8766
  };
7131
8767
  }
7132
8768
  async function searchShow(title, year) {
7133
- const apiKey = getApiKey();
8769
+ const apiKey = await getApiKey();
7134
8770
  const params = new URLSearchParams({
7135
8771
  api_key: apiKey,
7136
8772
  query: title
@@ -7141,14 +8777,14 @@ async function searchShow(title, year) {
7141
8777
  return (await response.json()).results;
7142
8778
  }
7143
8779
  async function getShowDetails(tmdbId) {
7144
- const apiKey = getApiKey();
8780
+ const apiKey = await getApiKey();
7145
8781
  const params = new URLSearchParams({ api_key: apiKey });
7146
8782
  const response = await fetch(`${tmdbBaseUrl}/tv/${tmdbId}?${params.toString()}`);
7147
8783
  if (!response.ok) throw new Error(`TMDB show details failed: ${response.statusText}`);
7148
8784
  return await response.json();
7149
8785
  }
7150
8786
  async function getSeasonDetails(tmdbId, seasonNumber) {
7151
- const apiKey = getApiKey();
8787
+ const apiKey = await getApiKey();
7152
8788
  const params = new URLSearchParams({ api_key: apiKey });
7153
8789
  const response = await fetch(`${tmdbBaseUrl}/tv/${tmdbId}/season/${seasonNumber}?${params.toString()}`);
7154
8790
  if (!response.ok) throw new Error(`TMDB season details failed: ${response.statusText}`);
@@ -7156,7 +8792,7 @@ async function getSeasonDetails(tmdbId, seasonNumber) {
7156
8792
  }
7157
8793
  async function fetchShowMetadata(title, year) {
7158
8794
  try {
7159
- getApiKey();
8795
+ await getApiKey();
7160
8796
  } catch {
7161
8797
  return null;
7162
8798
  }
@@ -7179,7 +8815,7 @@ async function fetchShowMetadata(title, year) {
7179
8815
  }
7180
8816
  async function fetchSeasonMetadata(tmdbId, seasonNumber) {
7181
8817
  try {
7182
- getApiKey();
8818
+ await getApiKey();
7183
8819
  } catch {
7184
8820
  return null;
7185
8821
  }
@@ -7199,106 +8835,73 @@ async function fetchSeasonMetadata(tmdbId, seasonNumber) {
7199
8835
  };
7200
8836
  }
7201
8837
  //#endregion
7202
- //#region src/services/filename-parser.ts
7203
- /**
7204
- * Parse a movie filename to extract title and year.
7205
- * The year marks the boundary between the title and technical info.
7206
- *
7207
- * Examples:
7208
- * - Jurassic.Park.1993.720p.BrRip.264.YIFY.mp4 -> { title: "Jurassic Park", year: 1993 }
7209
- * - The.Social.Network.(2010).1080p.BrRip.x264.mp4 -> { title: "The Social Network", year: 2010 }
7210
- */
7211
- function parseFilename(fileName) {
7212
- const name = fileName.replace(/\.[^.]+$/, "");
7213
- const bracketYearMatch = name.match(/[([]((?:19|20)\d{2})[)\]]/);
7214
- if (bracketYearMatch?.[1]) {
7215
- const year = parseInt(bracketYearMatch[1], 10);
7216
- const yearIndex = bracketYearMatch.index ?? 0;
7217
- let title = name.substring(0, yearIndex);
7218
- title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
7219
- title = title.replace(/[-–—]+$/, "").trim();
7220
- return {
7221
- title,
7222
- year
7223
- };
7224
- }
7225
- const yearPattern = /[.\s]((?:19|20)\d{2})[.\s]/g;
7226
- let match;
7227
- let bestMatch = null;
7228
- while ((match = yearPattern.exec(name)) !== null) {
7229
- const yearStr = match[1];
7230
- if (!yearStr) continue;
7231
- const year = parseInt(yearStr, 10);
7232
- const afterYear = name.substring(match.index + match[0].length);
7233
- const looksLikeTechInfo = /^(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|HDTV|DVDRip|x264|x265|H\.?264)/i.test(afterYear);
7234
- if (looksLikeTechInfo || !bestMatch) {
7235
- bestMatch = {
7236
- year,
7237
- index: match.index
7238
- };
7239
- if (looksLikeTechInfo) break;
7240
- }
7241
- }
7242
- if (bestMatch) {
7243
- let title = name.substring(0, bestMatch.index);
7244
- title = title.replace(/[._]/g, " ").replace(/\s+/g, " ").trim();
7245
- title = title.replace(/[-–—]+$/, "").trim();
7246
- return {
7247
- title,
7248
- year: bestMatch.year
7249
- };
7250
- }
7251
- return {
7252
- title: name.replace(/[._]/g, " ").replace(/\s+/g, " ").trim(),
7253
- year: null
7254
- };
7255
- }
8838
+ //#region src/services/subtitle-sync.ts
7256
8839
  /**
7257
- * Extract year from a filename if present
8840
+ * Replace the subtitle rows attached to a movie with the freshly discovered
8841
+ * sidecars. We delete-and-reinsert rather than diffing so a renamed or
8842
+ * removed sidecar disappears on the next scan without bookkeeping.
7258
8843
  */
7259
- function extractYear(fileName) {
7260
- return parseFilename(fileName).year;
8844
+ async function syncSubtitlesForMovie(movieId, discovered) {
8845
+ await db.delete(subtitles).where(eq(subtitles.movieId, movieId));
8846
+ if (discovered.length === 0) return;
8847
+ const rows = discovered.map((subtitle) => ({
8848
+ movieId,
8849
+ episodeId: null,
8850
+ filePath: subtitle.filePath,
8851
+ format: subtitle.format,
8852
+ language: subtitle.language
8853
+ }));
8854
+ await db.insert(subtitles).values(rows);
7261
8855
  }
7262
8856
  /**
7263
- * Clean a movie filename to extract a readable title
8857
+ * Same as syncSubtitlesForMovie but for episodes. See note above.
7264
8858
  */
7265
- function cleanTitle(fileName) {
7266
- return parseFilename(fileName).title;
8859
+ async function syncSubtitlesForEpisode(episodeId, discovered) {
8860
+ await db.delete(subtitles).where(eq(subtitles.episodeId, episodeId));
8861
+ if (discovered.length === 0) return;
8862
+ const rows = discovered.map((subtitle) => ({
8863
+ movieId: null,
8864
+ episodeId,
8865
+ filePath: subtitle.filePath,
8866
+ format: subtitle.format,
8867
+ language: subtitle.language
8868
+ }));
8869
+ await db.insert(subtitles).values(rows);
7267
8870
  }
7268
8871
  //#endregion
7269
8872
  //#region src/services/scanner.ts
7270
- const VIDEO_EXTENSIONS = new Set([
7271
- ".mp4",
7272
- ".mkv",
7273
- ".avi",
7274
- ".mov",
7275
- ".wmv",
7276
- ".m4v",
7277
- ".webm",
7278
- ".flv",
7279
- ".mpeg",
7280
- ".mpg"
7281
- ]);
7282
8873
  /**
7283
- * Recursively scan a directory for video files
8874
+ * Recursively scan a directory for video files. Each yielded entry includes
8875
+ * the subtitle siblings sitting in the same folder so subtitle discovery
8876
+ * piggybacks on the directory read instead of re-globbing per file.
7284
8877
  */
7285
8878
  async function* scanDirectory(dir) {
7286
8879
  const entries = await readdir(dir, { withFileTypes: true });
8880
+ const videoFiles = [];
8881
+ const subtitleSiblings = [];
8882
+ const subdirectories = [];
7287
8883
  for (const entry of entries) {
7288
- const fullPath = join(dir, entry.name);
7289
- if (entry.isDirectory()) yield* scanDirectory(fullPath);
7290
- else if (entry.isFile()) {
7291
- const ext = extname(entry.name).toLowerCase();
7292
- if (VIDEO_EXTENSIONS.has(ext)) yield fullPath;
8884
+ if (entry.isDirectory()) {
8885
+ subdirectories.push(entry.name);
8886
+ continue;
7293
8887
  }
7294
- }
8888
+ if (!entry.isFile()) continue;
8889
+ const extension = extname(entry.name).toLowerCase();
8890
+ if (videoExtensions$1.has(extension)) videoFiles.push(entry.name);
8891
+ else if (subtitleExtensions.has(extension)) subtitleSiblings.push(entry.name);
8892
+ }
8893
+ for (const videoFile of videoFiles) yield {
8894
+ filePath: join(dir, videoFile),
8895
+ subtitleSiblings
8896
+ };
8897
+ for (const subdirectory of subdirectories) yield* scanDirectory(join(dir, subdirectory));
7295
8898
  }
7296
8899
  async function scanLibrary(libraryPath, onProgress) {
7297
8900
  let added = 0;
7298
8901
  let updated = 0;
7299
8902
  let total = 0;
7300
8903
  console.log(`Scanning: ${libraryPath}`);
7301
- for await (const filePath of scanDirectory(libraryPath)) {
8904
+ for await (const { filePath, subtitleSiblings } of scanDirectory(libraryPath)) {
7302
8905
  total++;
7303
8906
  const fileName = basename(filePath);
7304
8907
  const ext = extname(fileName).toLowerCase();
@@ -7309,9 +8912,12 @@ async function scanLibrary(libraryPath, onProgress) {
7309
8912
  fileSize = (await stat(filePath)).size;
7310
8913
  } catch {}
7311
8914
  const now = /* @__PURE__ */ new Date();
8915
+ const discoveredSubtitles = discoverSubtitlesForVideo(filePath, subtitleSiblings);
7312
8916
  const existing = await db.select().from(movies).where(eq(movies.filePath, filePath)).limit(1);
8917
+ let movieId;
7313
8918
  if (existing.length > 0 && existing[0]) {
7314
8919
  const movie = existing[0];
8920
+ movieId = movie.id;
7315
8921
  let tmdbData = {};
7316
8922
  if (!movie.tmdbId) {
7317
8923
  console.log(` Fetching TMDB metadata for: ${title}`);
@@ -7369,7 +8975,7 @@ async function scanLibrary(libraryPath, onProgress) {
7369
8975
  backdropPath: metadata?.backdropPath ?? null,
7370
8976
  trailerUrl: metadata?.trailerUrl ?? null
7371
8977
  };
7372
- await db.insert(movies).values(newMovie);
8978
+ movieId = (await db.insert(movies).values(newMovie).returning({ id: movies.id }))[0]?.id ?? null;
7373
8979
  added++;
7374
8980
  await onProgress?.({
7375
8981
  added,
@@ -7377,6 +8983,7 @@ async function scanLibrary(libraryPath, onProgress) {
7377
8983
  updated
7378
8984
  });
7379
8985
  }
8986
+ if (movieId !== null) await syncSubtitlesForMovie(movieId, discoveredSubtitles);
7380
8987
  console.log(` Found: ${title}`);
7381
8988
  }
7382
8989
  return {
@@ -7387,7 +8994,7 @@ async function scanLibrary(libraryPath, onProgress) {
7387
8994
  }
7388
8995
  //#endregion
7389
8996
  //#region src/services/episode-parser.ts
7390
- const videoExtensions$1 = new Set([
8997
+ const videoExtensions = new Set([
7391
8998
  ".mp4",
7392
8999
  ".mkv",
7393
9000
  ".avi",
@@ -7403,7 +9010,7 @@ const episodePattern = /S(\d+)[Ee](\d+)/;
7403
9010
  const technicalPatterns = /\b(720p|1080p|2160p|4K|BluRay|BrRip|BDRip|WEB|WEB-DL|HDTV|DVDRip|x264|x265|H\.?264|HEVC|AAC|AC3|DD5|10bit)\b/i;
7404
9011
  function parseEpisodeFilename(fileName) {
7405
9012
  const ext = extname(fileName).toLowerCase();
7406
- if (!videoExtensions$1.has(ext)) return null;
9013
+ if (!videoExtensions.has(ext)) return null;
7407
9014
  const name = fileName.replace(/\.[^.]+$/, "");
7408
9015
  const match = name.match(episodePattern);
7409
9016
  if (!match?.[1] || !match[2]) return null;
@@ -7497,18 +9104,6 @@ function parseSeasonFolder(folderName) {
7497
9104
  }
7498
9105
  //#endregion
7499
9106
  //#region src/services/show-scanner.ts
7500
- const videoExtensions = new Set([
7501
- ".mp4",
7502
- ".mkv",
7503
- ".avi",
7504
- ".mov",
7505
- ".wmv",
7506
- ".m4v",
7507
- ".webm",
7508
- ".flv",
7509
- ".mpeg",
7510
- ".mpg"
7511
- ]);
7512
9107
  async function scanEpisodesInDirectory(directory) {
7513
9108
  let entries;
7514
9109
  try {
@@ -7516,10 +9111,16 @@ async function scanEpisodesInDirectory(directory) {
7516
9111
  } catch {
7517
9112
  return [];
7518
9113
  }
7519
- const episodes = [];
9114
+ const subtitleSiblings = [];
9115
+ const videoEntries = [];
7520
9116
  for (const entry of entries) {
7521
9117
  const extension = extname(entry).toLowerCase();
7522
- if (!videoExtensions.has(extension)) continue;
9118
+ if (videoExtensions$1.has(extension)) videoEntries.push(entry);
9119
+ else if (subtitleExtensions.has(extension)) subtitleSiblings.push(entry);
9120
+ }
9121
+ const episodes = [];
9122
+ for (const entry of videoEntries) {
9123
+ const extension = extname(entry).toLowerCase();
7523
9124
  const parsed = parseEpisodeFilename(entry);
7524
9125
  if (!parsed) continue;
7525
9126
  const filePath = join(directory, entry);
@@ -7534,7 +9135,8 @@ async function scanEpisodesInDirectory(directory) {
7534
9135
  extension,
7535
9136
  seasonNumber: parsed.seasonNumber,
7536
9137
  episodeNumber: parsed.episodeNumber,
7537
- title: parsed.title
9138
+ title: parsed.title,
9139
+ subtitleSiblings
7538
9140
  });
7539
9141
  }
7540
9142
  return episodes;
@@ -7694,7 +9296,9 @@ async function scanShowLibrary(libraryPath, onProgress) {
7694
9296
  const episodeTitle = tmdbEpisode?.title ?? scannedEpisode.title ?? `Episode ${scannedEpisode.episodeNumber}`;
7695
9297
  const existingEpisodes = await db.select().from(episodes).where(eq(episodes.filePath, scannedEpisode.filePath)).limit(1);
7696
9298
  const now = /* @__PURE__ */ new Date();
7697
- if (existingEpisodes.length > 0) {
9299
+ let episodeId;
9300
+ if (existingEpisodes.length > 0 && existingEpisodes[0]) {
9301
+ episodeId = existingEpisodes[0].id;
7698
9302
  await db.update(episodes).set({
7699
9303
  title: episodeTitle,
7700
9304
  fileName: scannedEpisode.fileName,
@@ -7726,7 +9330,7 @@ async function scanShowLibrary(libraryPath, onProgress) {
7726
9330
  createdAt: now,
7727
9331
  updatedAt: now
7728
9332
  };
7729
- await db.insert(episodes).values(newEpisode);
9333
+ episodeId = (await db.insert(episodes).values(newEpisode).returning({ id: episodes.id }))[0]?.id ?? null;
7730
9334
  added++;
7731
9335
  await onProgress?.({
7732
9336
  added,
@@ -7734,6 +9338,10 @@ async function scanShowLibrary(libraryPath, onProgress) {
7734
9338
  updated
7735
9339
  });
7736
9340
  }
9341
+ if (episodeId !== null) {
9342
+ const discoveredSubtitles = discoverSubtitlesForVideo(scannedEpisode.filePath, scannedEpisode.subtitleSiblings);
9343
+ await syncSubtitlesForEpisode(episodeId, discoveredSubtitles);
9344
+ }
7737
9345
  }
7738
9346
  }
7739
9347
  console.log(` Found show: ${name} (${episodes$1.length} episodes)`);
@@ -7745,85 +9353,746 @@ async function scanShowLibrary(libraryPath, onProgress) {
7745
9353
  };
7746
9354
  }
7747
9355
  //#endregion
7748
- //#region src/api/routes/scan.ts
7749
- const scanRoutes = new Hono();
7750
- scanRoutes.get("/libraries", async (context) => {
7751
- const libraries$3 = await db.select().from(libraries);
7752
- return context.json(libraries$3.map((library) => ({
7753
- id: library.id,
7754
- name: library.name,
7755
- path: library.path,
7756
- type: library.type
7757
- })));
7758
- });
7759
- scanRoutes.get("/stream", async (context) => {
7760
- const libraries$2 = await db.select().from(libraries);
7761
- if (libraries$2.length === 0) return context.json({ error: { message: "No libraries configured" } }, 400);
7762
- return streamSSE(context, async (stream) => {
9356
+ //#region src/services/scan-manager.ts
9357
+ const interruptedMessage = "Server restarted mid-scan. Run a manual scan to resume.";
9358
+ function createScanManager(dependencies = {}) {
9359
+ const database = dependencies.database ?? db;
9360
+ const scanLibrary$1 = dependencies.scanLibrary ?? scanLibrary;
9361
+ const scanShowLibrary$1 = dependencies.scanShowLibrary ?? scanShowLibrary;
9362
+ const emitter = new EventEmitter();
9363
+ emitter.setMaxListeners(0);
9364
+ let currentJob = null;
9365
+ async function getStatus() {
9366
+ if (currentJob) {
9367
+ const [latest] = await database.select().from(scanJobs).where(eq(scanJobs.id, currentJob.id)).limit(1);
9368
+ return {
9369
+ currentJob: latest ?? currentJob,
9370
+ isRunning: true,
9371
+ lastJob: latest ?? currentJob
9372
+ };
9373
+ }
9374
+ const [last] = await database.select().from(scanJobs).orderBy(desc(scanJobs.startedAt)).limit(1);
9375
+ return {
9376
+ currentJob: null,
9377
+ isRunning: false,
9378
+ lastJob: last ?? null
9379
+ };
9380
+ }
9381
+ function isRunning() {
9382
+ return currentJob !== null;
9383
+ }
9384
+ async function recoverInterruptedJobs() {
9385
+ const now = /* @__PURE__ */ new Date();
9386
+ const running = await database.select().from(scanJobs).where(eq(scanJobs.status, "running"));
9387
+ if (running.length === 0) return;
9388
+ for (const job of running) await database.update(scanJobs).set({
9389
+ endedAt: now,
9390
+ errorMessage: interruptedMessage,
9391
+ status: "error"
9392
+ }).where(eq(scanJobs.id, job.id));
9393
+ log.info(`Marked ${running.length} interrupted scan job(s) as failed after a restart.`);
9394
+ }
9395
+ async function start() {
9396
+ if (currentJob) return { status: "already-running" };
9397
+ const libraries$4 = await database.select().from(libraries);
9398
+ if (libraries$4.length === 0) return {
9399
+ status: "no-libraries",
9400
+ message: "No libraries configured. Add a library in Settings before scanning."
9401
+ };
9402
+ const startedAt = /* @__PURE__ */ new Date();
9403
+ const [inserted] = await database.insert(scanJobs).values({
9404
+ startedAt,
9405
+ status: "running",
9406
+ added: 0,
9407
+ updated: 0,
9408
+ total: 0
9409
+ }).returning();
9410
+ invariant(inserted, "Failed to create scan_jobs row.");
9411
+ currentJob = inserted;
9412
+ emitter.emit("job-started", {
9413
+ jobId: inserted.id,
9414
+ startedAt: inserted.startedAt
9415
+ });
7763
9416
  let totalAdded = 0;
7764
9417
  let totalUpdated = 0;
7765
9418
  let totalFound = 0;
7766
- for (let i = 0; i < libraries$2.length; i++) {
7767
- const library = libraries$2[i];
7768
- if (!library) continue;
7769
- await stream.writeSSE({
7770
- event: "library-start",
7771
- data: JSON.stringify({ index: i })
7772
- });
7773
- try {
9419
+ const libraryErrors = [];
9420
+ try {
9421
+ for (let index = 0; index < libraries$4.length; index++) {
9422
+ const library = libraries$4[index];
9423
+ if (!library) continue;
9424
+ const libraryType = library.type === "shows" ? "shows" : "movies";
9425
+ emitter.emit("library-start", {
9426
+ index,
9427
+ libraryId: library.id,
9428
+ name: library.name,
9429
+ type: libraryType
9430
+ });
7774
9431
  const onProgress = async (progress) => {
7775
- await stream.writeSSE({
7776
- event: "file-scanned",
7777
- data: JSON.stringify({
7778
- index: i,
7779
- ...progress
7780
- })
9432
+ const aggregate = {
9433
+ added: totalAdded + progress.added,
9434
+ total: totalFound + progress.total,
9435
+ updated: totalUpdated + progress.updated
9436
+ };
9437
+ await database.update(scanJobs).set(aggregate).where(eq(scanJobs.id, inserted.id));
9438
+ emitter.emit("file-scanned", {
9439
+ ...progress,
9440
+ index,
9441
+ libraryId: library.id
7781
9442
  });
7782
9443
  };
7783
- let result;
7784
- if (library.type === "movies") result = await scanLibrary(library.path, onProgress);
7785
- else result = await scanShowLibrary(library.path, onProgress);
7786
- totalAdded += result.added;
7787
- totalUpdated += result.updated;
7788
- totalFound += result.total;
7789
- await stream.writeSSE({
7790
- event: "library-complete",
7791
- data: JSON.stringify({
7792
- added: result.added,
7793
- index: i,
7794
- total: result.total,
7795
- updated: result.updated
7796
- })
7797
- });
7798
- } catch (error) {
7799
- await stream.writeSSE({
7800
- event: "library-error",
7801
- data: JSON.stringify({
7802
- error: error instanceof Error ? error.message : "Unknown error",
7803
- index: i
7804
- })
7805
- });
9444
+ try {
9445
+ const result = libraryType === "shows" ? await scanShowLibrary$1(library.path, onProgress) : await scanLibrary$1(library.path, onProgress);
9446
+ totalAdded += result.added;
9447
+ totalUpdated += result.updated;
9448
+ totalFound += result.total;
9449
+ emitter.emit("library-complete", {
9450
+ ...result,
9451
+ index,
9452
+ libraryId: library.id
9453
+ });
9454
+ } catch (caughtError) {
9455
+ const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
9456
+ libraryErrors.push(`${library.name} — ${message}`);
9457
+ emitter.emit("library-error", {
9458
+ error: message,
9459
+ index,
9460
+ libraryId: library.id
9461
+ });
9462
+ }
7806
9463
  }
7807
- }
7808
- await stream.writeSSE({
7809
- event: "scan-complete",
7810
- data: JSON.stringify({
9464
+ const endedAt = /* @__PURE__ */ new Date();
9465
+ const status = libraryErrors.length > 0 ? "error" : "done";
9466
+ const errorMessage = libraryErrors.length > 0 ? libraryErrors.join("; ") : null;
9467
+ await database.update(scanJobs).set({
7811
9468
  added: totalAdded,
7812
- found: totalFound,
9469
+ endedAt,
9470
+ errorMessage,
9471
+ status,
9472
+ total: totalFound,
7813
9473
  updated: totalUpdated
7814
- })
9474
+ }).where(eq(scanJobs.id, inserted.id));
9475
+ const payload = {
9476
+ added: totalAdded,
9477
+ errorMessage,
9478
+ jobId: inserted.id,
9479
+ status,
9480
+ total: totalFound,
9481
+ updated: totalUpdated
9482
+ };
9483
+ emitter.emit("job-completed", payload);
9484
+ return {
9485
+ added: totalAdded,
9486
+ errorMessage: errorMessage ?? void 0,
9487
+ status,
9488
+ total: totalFound,
9489
+ updated: totalUpdated
9490
+ };
9491
+ } catch (caughtError) {
9492
+ const endedAt = /* @__PURE__ */ new Date();
9493
+ const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
9494
+ await database.update(scanJobs).set({
9495
+ added: totalAdded,
9496
+ endedAt,
9497
+ errorMessage: message,
9498
+ status: "error",
9499
+ total: totalFound,
9500
+ updated: totalUpdated
9501
+ }).where(eq(scanJobs.id, inserted.id));
9502
+ emitter.emit("job-failed", {
9503
+ error: message,
9504
+ jobId: inserted.id
9505
+ });
9506
+ return {
9507
+ added: totalAdded,
9508
+ errorMessage: message,
9509
+ status: "error",
9510
+ total: totalFound,
9511
+ updated: totalUpdated
9512
+ };
9513
+ } finally {
9514
+ currentJob = null;
9515
+ }
9516
+ }
9517
+ return {
9518
+ getStatus,
9519
+ isRunning,
9520
+ off: (event, listener) => emitter.off(event, listener),
9521
+ on: (event, listener) => emitter.on(event, listener),
9522
+ recoverInterruptedJobs,
9523
+ start
9524
+ };
9525
+ }
9526
+ const scanManager = createScanManager();
9527
+ //#endregion
9528
+ //#region src/api/routes/scan.ts
9529
+ const scanRoutes = new Hono();
9530
+ scanRoutes.get("/libraries", async (context) => {
9531
+ const libraries$3 = await db.select().from(libraries);
9532
+ return context.json(libraries$3.map((library) => ({
9533
+ id: library.id,
9534
+ name: library.name,
9535
+ path: library.path,
9536
+ type: library.type
9537
+ })));
9538
+ });
9539
+ scanRoutes.get("/status", async (context) => {
9540
+ const status = await scanManager.getStatus();
9541
+ return context.json({
9542
+ currentJob: status.currentJob ? {
9543
+ added: status.currentJob.added,
9544
+ endedAt: status.currentJob.endedAt?.toISOString() ?? null,
9545
+ errorMessage: status.currentJob.errorMessage,
9546
+ id: status.currentJob.id,
9547
+ startedAt: status.currentJob.startedAt.toISOString(),
9548
+ status: status.currentJob.status,
9549
+ total: status.currentJob.total,
9550
+ updated: status.currentJob.updated
9551
+ } : null,
9552
+ isRunning: status.isRunning,
9553
+ lastJob: status.lastJob ? {
9554
+ added: status.lastJob.added,
9555
+ endedAt: status.lastJob.endedAt?.toISOString() ?? null,
9556
+ errorMessage: status.lastJob.errorMessage,
9557
+ id: status.lastJob.id,
9558
+ startedAt: status.lastJob.startedAt.toISOString(),
9559
+ status: status.lastJob.status,
9560
+ total: status.lastJob.total,
9561
+ updated: status.lastJob.updated
9562
+ } : null
9563
+ });
9564
+ });
9565
+ scanRoutes.post("/start", async (context) => {
9566
+ if ((await db.select().from(libraries)).length === 0) return context.json({ error: { message: "No libraries configured. Add a library in Settings before scanning." } }, 400);
9567
+ if (scanManager.isRunning()) return context.json({ status: "already-running" });
9568
+ scanManager.start().catch((caughtError) => {
9569
+ const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
9570
+ console.error(`Manual scan failed: ${message}`);
9571
+ });
9572
+ return context.json({ status: "started" });
9573
+ });
9574
+ scanRoutes.get("/stream", (context) => {
9575
+ return streamSSE(context, async (stream) => {
9576
+ const onLibraryStart = (event) => {
9577
+ stream.writeSSE({
9578
+ event: "library-start",
9579
+ data: JSON.stringify(event)
9580
+ });
9581
+ };
9582
+ const onFileScanned = (event) => {
9583
+ stream.writeSSE({
9584
+ event: "file-scanned",
9585
+ data: JSON.stringify(event)
9586
+ });
9587
+ };
9588
+ const onLibraryComplete = (event) => {
9589
+ stream.writeSSE({
9590
+ event: "library-complete",
9591
+ data: JSON.stringify(event)
9592
+ });
9593
+ };
9594
+ const onLibraryError = (event) => {
9595
+ stream.writeSSE({
9596
+ event: "library-error",
9597
+ data: JSON.stringify(event)
9598
+ });
9599
+ };
9600
+ const onScanComplete = (event) => {
9601
+ stream.writeSSE({
9602
+ event: "scan-complete",
9603
+ data: JSON.stringify({
9604
+ added: event.added,
9605
+ errorMessage: event.errorMessage,
9606
+ found: event.total,
9607
+ status: event.status,
9608
+ updated: event.updated
9609
+ })
9610
+ });
9611
+ };
9612
+ scanManager.on("library-start", onLibraryStart);
9613
+ scanManager.on("file-scanned", onFileScanned);
9614
+ scanManager.on("library-complete", onLibraryComplete);
9615
+ scanManager.on("library-error", onLibraryError);
9616
+ scanManager.on("job-completed", onScanComplete);
9617
+ await stream.writeSSE({
9618
+ event: "ready",
9619
+ data: JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString() })
7815
9620
  });
9621
+ stream.onAbort(() => {
9622
+ scanManager.off("library-start", onLibraryStart);
9623
+ scanManager.off("file-scanned", onFileScanned);
9624
+ scanManager.off("library-complete", onLibraryComplete);
9625
+ scanManager.off("library-error", onLibraryError);
9626
+ scanManager.off("job-completed", onScanComplete);
9627
+ });
9628
+ const keepAliveMs = 15e3;
9629
+ try {
9630
+ while (!stream.aborted && !stream.closed) {
9631
+ await stream.sleep(keepAliveMs);
9632
+ if (stream.aborted || stream.closed) break;
9633
+ await stream.writeSSE({
9634
+ event: "ping",
9635
+ data: "{}"
9636
+ });
9637
+ }
9638
+ } catch {}
9639
+ });
9640
+ });
9641
+ //#endregion
9642
+ //#region src/services/fts-query-parser.ts
9643
+ /**
9644
+ * Convert raw user input into a safe SQLite FTS5 MATCH expression.
9645
+ *
9646
+ * FTS5 has a query syntax with operators (AND, OR, NEAR, NOT), prefix
9647
+ * wildcards (*), column filters (title:foo), and quoted phrases. We don't
9648
+ * want users to accidentally trigger any of that — and we don't want
9649
+ * adversarial input to crash the parser. So we:
9650
+ *
9651
+ * 1. Split the input on whitespace.
9652
+ * 2. Strip every character except letters, digits, and the underscore
9653
+ * from each token. This drops quotes, asterisks, parens, colons,
9654
+ * and operator-like punctuation.
9655
+ * 3. Wrap each surviving token in double quotes (so reserved words like
9656
+ * `OR` or `NEAR` are treated as literal terms) and append `*` for
9657
+ * prefix matching.
9658
+ * 4. Join the tokens with spaces. FTS5 implicitly ANDs them.
9659
+ *
9660
+ * Returns null when there is nothing searchable (empty input, only
9661
+ * whitespace, or only stripped characters).
9662
+ */
9663
+ function buildFtsMatchQuery(rawInput) {
9664
+ const tokens = rawInput.split(/\s+/).map((token) => sanitizeToken(token)).filter((token) => token.length > 0);
9665
+ if (tokens.length === 0) return null;
9666
+ return tokens.map((token) => `"${token}"*`).join(" ");
9667
+ }
9668
+ function sanitizeToken(token) {
9669
+ return token.replace(/[^\p{L}\p{N}_]+/gu, "");
9670
+ }
9671
+ //#endregion
9672
+ //#region src/api/routes/search.ts
9673
+ const defaultLimit = 20;
9674
+ const maxLimit = 50;
9675
+ const maxQueryLength = 256;
9676
+ const searchRoutes = new Hono();
9677
+ searchRoutes.get("/", (context) => {
9678
+ const queryParameter = context.req.query("q");
9679
+ if (queryParameter === void 0) return context.json({ error: { message: "Add a `q` query parameter to search. Example: /api/search?q=dune" } }, 400);
9680
+ if (queryParameter.length > maxQueryLength) return context.json({ error: { message: "Search query is too long. Shorten it to under 256 characters." } }, 400);
9681
+ const limit = parseLimit(context.req.query("limit"));
9682
+ if (limit === null) return context.json({ error: { message: "`limit` must be a positive integer between 1 and 50." } }, 400);
9683
+ const matchExpression = buildFtsMatchQuery(queryParameter);
9684
+ if (matchExpression === null) return context.json({
9685
+ episodes: [],
9686
+ indexEmpty: false,
9687
+ movies: [],
9688
+ shows: []
7816
9689
  });
9690
+ try {
9691
+ const movies = searchMovies(matchExpression, limit);
9692
+ const shows = searchShows(matchExpression, limit);
9693
+ const episodes = searchEpisodes(matchExpression, limit);
9694
+ const response = {
9695
+ episodes,
9696
+ indexEmpty: movies.length === 0 && shows.length === 0 && episodes.length === 0 && isLibraryEmpty(),
9697
+ movies,
9698
+ shows
9699
+ };
9700
+ return context.json(response);
9701
+ } catch (error) {
9702
+ log.error("Search query failed.", error);
9703
+ return context.json({ error: { message: "Your search couldn't be parsed. Try simpler terms or fewer special characters." } }, 400);
9704
+ }
9705
+ });
9706
+ function isLibraryEmpty() {
9707
+ const [movieCountRow] = db.all(sql`
9708
+ SELECT COUNT(*) AS count FROM movies
9709
+ `);
9710
+ const [showCountRow] = db.all(sql`
9711
+ SELECT COUNT(*) AS count FROM shows
9712
+ `);
9713
+ const [episodeCountRow] = db.all(sql`
9714
+ SELECT COUNT(*) AS count FROM episodes
9715
+ `);
9716
+ return (movieCountRow?.count ?? 0) + (showCountRow?.count ?? 0) + (episodeCountRow?.count ?? 0) === 0;
9717
+ }
9718
+ function parseLimit(raw) {
9719
+ if (raw === void 0 || raw.trim() === "") return defaultLimit;
9720
+ const parsed = Number(raw);
9721
+ if (!Number.isInteger(parsed) || parsed < 1) return null;
9722
+ return Math.min(parsed, maxLimit);
9723
+ }
9724
+ function searchMovies(matchExpression, limit) {
9725
+ return db.all(sql`
9726
+ SELECT
9727
+ movies.id AS id,
9728
+ movies.title AS title,
9729
+ movies.year AS year,
9730
+ movies.overview AS overview,
9731
+ movies.poster_path AS poster_path,
9732
+ movies.backdrop_path AS backdrop_path
9733
+ FROM movies_fts
9734
+ JOIN movies ON movies.rowid = movies_fts.rowid
9735
+ WHERE movies_fts MATCH ${matchExpression}
9736
+ ORDER BY bm25(movies_fts) ASC
9737
+ LIMIT ${limit}
9738
+ `).map((row) => ({
9739
+ backdropPath: row.backdrop_path,
9740
+ id: row.id,
9741
+ overview: row.overview,
9742
+ posterPath: row.poster_path,
9743
+ title: row.title,
9744
+ year: row.year
9745
+ }));
9746
+ }
9747
+ function searchShows(matchExpression, limit) {
9748
+ return db.all(sql`
9749
+ SELECT
9750
+ shows.id AS id,
9751
+ shows.title AS title,
9752
+ shows.year AS year,
9753
+ shows.overview AS overview,
9754
+ shows.poster_path AS poster_path,
9755
+ shows.backdrop_path AS backdrop_path
9756
+ FROM shows_fts
9757
+ JOIN shows ON shows.rowid = shows_fts.rowid
9758
+ WHERE shows_fts MATCH ${matchExpression}
9759
+ ORDER BY bm25(shows_fts) ASC
9760
+ LIMIT ${limit}
9761
+ `).map((row) => ({
9762
+ backdropPath: row.backdrop_path,
9763
+ id: row.id,
9764
+ overview: row.overview,
9765
+ posterPath: row.poster_path,
9766
+ title: row.title,
9767
+ year: row.year
9768
+ }));
9769
+ }
9770
+ function searchEpisodes(matchExpression, limit) {
9771
+ return db.all(sql`
9772
+ SELECT
9773
+ episodes.id AS id,
9774
+ episodes.show_id AS show_id,
9775
+ episodes.season_number AS season_number,
9776
+ episodes.episode_number AS episode_number,
9777
+ episodes.title AS title,
9778
+ episodes.overview AS overview,
9779
+ episodes.still_path AS still_path,
9780
+ shows.title AS show_title
9781
+ FROM episodes_fts
9782
+ JOIN episodes ON episodes.rowid = episodes_fts.rowid
9783
+ LEFT JOIN shows ON shows.id = episodes.show_id
9784
+ WHERE episodes_fts MATCH ${matchExpression}
9785
+ ORDER BY bm25(episodes_fts) ASC
9786
+ LIMIT ${limit}
9787
+ `).map((row) => ({
9788
+ episodeNumber: row.episode_number,
9789
+ id: row.id,
9790
+ overview: row.overview,
9791
+ seasonNumber: row.season_number,
9792
+ showId: row.show_id,
9793
+ showTitle: row.show_title ?? "",
9794
+ stillPath: row.still_path,
9795
+ title: row.title
9796
+ }));
9797
+ }
9798
+ //#endregion
9799
+ //#region src/services/scheduler.ts
9800
+ const millisecondsPerHour = 3600 * 1e3;
9801
+ function scheduleIntervalMilliseconds(value) {
9802
+ switch (value) {
9803
+ case "off": return null;
9804
+ case "6h": return 6 * millisecondsPerHour;
9805
+ case "12h": return 12 * millisecondsPerHour;
9806
+ case "24h": return 24 * millisecondsPerHour;
9807
+ }
9808
+ }
9809
+ async function defaultGetSchedule() {
9810
+ const stored = await getSetting(scanScheduleSettingKey);
9811
+ if (stored === null) return "off";
9812
+ if (isScanScheduleValue(stored)) return stored;
9813
+ log.warn(`Unknown scanSchedule value "${stored}" in settings. Falling back to "off".`);
9814
+ return "off";
9815
+ }
9816
+ function createScheduler(dependencies = {}) {
9817
+ const getSchedule = dependencies.getSchedule ?? defaultGetSchedule;
9818
+ const scanManager$1 = dependencies.scanManager ?? {
9819
+ isRunning: () => scanManager.isRunning(),
9820
+ start: () => scanManager.start()
9821
+ };
9822
+ let currentSchedule = "off";
9823
+ let nextRunAt = null;
9824
+ let timer = null;
9825
+ function clearTimer() {
9826
+ if (timer !== null) {
9827
+ clearTimeout(timer);
9828
+ timer = null;
9829
+ }
9830
+ }
9831
+ function scheduleNext(intervalMs) {
9832
+ clearTimer();
9833
+ nextRunAt = new Date(Date.now() + intervalMs);
9834
+ timer = setTimeout(() => {
9835
+ tick();
9836
+ }, intervalMs);
9837
+ }
9838
+ async function tick() {
9839
+ if (scanManager$1.isRunning()) log.debug("Scheduler tick skipped — a scan is already running.");
9840
+ else try {
9841
+ const result = await scanManager$1.start();
9842
+ if (result.status === "error") {
9843
+ const message = "errorMessage" in result && result.errorMessage ? `: ${result.errorMessage}` : ".";
9844
+ log.warn(`Scheduled scan finished with errors${message}`);
9845
+ } else if (result.status === "no-libraries") log.debug("Scheduled scan skipped — no libraries configured.");
9846
+ } catch (caughtError) {
9847
+ const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
9848
+ log.warn(`Scheduled scan failed to start: ${message}. Check the scan page.`);
9849
+ }
9850
+ const activeInterval = scheduleIntervalMilliseconds(currentSchedule);
9851
+ if (activeInterval === null) {
9852
+ nextRunAt = null;
9853
+ return;
9854
+ }
9855
+ scheduleNext(activeInterval);
9856
+ }
9857
+ function applySchedule(value) {
9858
+ currentSchedule = value;
9859
+ const intervalMs = scheduleIntervalMilliseconds(value);
9860
+ if (intervalMs === null) {
9861
+ clearTimer();
9862
+ nextRunAt = null;
9863
+ log.info("Scan scheduler is off.");
9864
+ return;
9865
+ }
9866
+ scheduleNext(intervalMs);
9867
+ log.info(`Next scheduled scan at ${nextRunAt?.toISOString() ?? "unknown"} (${value}).`);
9868
+ }
9869
+ return {
9870
+ getInfo: () => ({
9871
+ nextRunAt,
9872
+ schedule: currentSchedule
9873
+ }),
9874
+ start: async () => {
9875
+ applySchedule(await getSchedule());
9876
+ },
9877
+ stop: () => {
9878
+ clearTimer();
9879
+ currentSchedule = "off";
9880
+ nextRunAt = null;
9881
+ },
9882
+ updateSchedule: (value) => {
9883
+ applySchedule(value);
9884
+ }
9885
+ };
9886
+ }
9887
+ const scheduler = createScheduler();
9888
+ //#endregion
9889
+ //#region src/api/routes/settings.ts
9890
+ const tmdbConfigurationUrl = "https://api.themoviedb.org/3/configuration";
9891
+ const reservedKeys = new Set([tmdbApiKeySettingKey, scanScheduleSettingKey]);
9892
+ const reservedKeyRoute = {
9893
+ [tmdbApiKeySettingKey]: "/api/settings/tmdb-key",
9894
+ [scanScheduleSettingKey]: "/api/settings/scan-schedule"
9895
+ };
9896
+ async function verifyTmdbKey(apiKey) {
9897
+ try {
9898
+ return (await fetch(`${tmdbConfigurationUrl}?api_key=${encodeURIComponent(apiKey)}`)).ok;
9899
+ } catch {
9900
+ return false;
9901
+ }
9902
+ }
9903
+ async function pathIsReadable(path) {
9904
+ try {
9905
+ await access(path, constants.R_OK);
9906
+ return true;
9907
+ } catch {
9908
+ return false;
9909
+ }
9910
+ }
9911
+ function validateLibraryInput(input) {
9912
+ if (typeof input !== "object" || input === null) return "Library entry must be an object.";
9913
+ const record = input;
9914
+ const name = typeof record.name === "string" ? record.name.trim() : "";
9915
+ const libraryPath = typeof record.path === "string" ? record.path.trim() : "";
9916
+ const type = record.type;
9917
+ if (libraryPath.length === 0) return "Library path is required.";
9918
+ if (type !== "movies" && type !== "shows") return "Library type must be \"movies\" or \"shows\".";
9919
+ return {
9920
+ name,
9921
+ path: libraryPath,
9922
+ type
9923
+ };
9924
+ }
9925
+ /**
9926
+ * Build the LIKE pattern that matches any file path under the given library
9927
+ * root. Escapes SQL LIKE wildcards so a path containing `%` or `_` doesn't
9928
+ * accidentally match siblings.
9929
+ */
9930
+ function libraryPathPrefixPattern(libraryPath) {
9931
+ const resolved = path.resolve(libraryPath);
9932
+ return `${(resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`).replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`;
9933
+ }
9934
+ const settingsRoutes = new Hono();
9935
+ settingsRoutes.get("/tmdb-key", async (context) => {
9936
+ const apiKey = await getTmdbApiKey();
9937
+ return context.json({
9938
+ configured: apiKey !== null && apiKey.length > 0,
9939
+ apiKey: apiKey ?? ""
9940
+ });
9941
+ });
9942
+ settingsRoutes.put("/tmdb-key", async (context) => {
9943
+ let body;
9944
+ try {
9945
+ body = await context.req.json();
9946
+ } catch {
9947
+ return context.json({ error: { message: "Invalid request body." } }, 400);
9948
+ }
9949
+ const apiKey = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
9950
+ if (apiKey.length === 0) return context.json({ error: { message: "TMDB API key is required. Get one at themoviedb.org/settings/api." } }, 400);
9951
+ if (!await verifyTmdbKey(apiKey)) return context.json({ error: { message: "Couldn't save TMDB key — the key wasn't accepted by TMDB. Check it at themoviedb.org/settings/api." } }, 400);
9952
+ await setTmdbApiKey(apiKey);
9953
+ return context.json({ configured: true });
9954
+ });
9955
+ settingsRoutes.get("/libraries", async (context) => {
9956
+ const libraries$2 = await db.select().from(libraries);
9957
+ return context.json(libraries$2.map((library) => ({
9958
+ id: library.id,
9959
+ name: library.name,
9960
+ path: library.path,
9961
+ type: library.type
9962
+ })));
9963
+ });
9964
+ settingsRoutes.post("/libraries", async (context) => {
9965
+ let body;
9966
+ try {
9967
+ body = await context.req.json();
9968
+ } catch {
9969
+ return context.json({ error: { message: "Invalid request body." } }, 400);
9970
+ }
9971
+ const parsed = validateLibraryInput(body);
9972
+ if (typeof parsed === "string") return context.json({ error: { message: parsed } }, 400);
9973
+ if (!await pathIsReadable(parsed.path)) return context.json({ error: { message: `Library path doesn't exist or isn't readable: ${parsed.path}. Check the path and Jukebox's file permissions.` } }, 400);
9974
+ const [existing] = await db.select().from(libraries).where(eq(libraries.path, parsed.path)).limit(1);
9975
+ if (existing) return context.json({ error: { message: `A library at ${parsed.path} already exists.` } }, 400);
9976
+ const resolvedName = parsed.name.length > 0 ? parsed.name : parsed.path.split(/[\\/]/).filter(Boolean).pop() ?? parsed.type;
9977
+ const [created] = await db.insert(libraries).values({
9978
+ name: resolvedName,
9979
+ path: parsed.path,
9980
+ type: parsed.type,
9981
+ createdAt: /* @__PURE__ */ new Date()
9982
+ }).returning();
9983
+ if (!created) return context.json({ error: { message: "Failed to create library." } }, 500);
9984
+ return context.json({
9985
+ id: created.id,
9986
+ name: created.name,
9987
+ path: created.path,
9988
+ type: created.type
9989
+ }, 201);
9990
+ });
9991
+ settingsRoutes.delete("/libraries/:id", async (context) => {
9992
+ const id = Number.parseInt(context.req.param("id"), 10);
9993
+ if (!Number.isFinite(id)) return context.json({ error: { message: "Invalid library id." } }, 400);
9994
+ const [existing] = await db.select().from(libraries).where(eq(libraries.id, id)).limit(1);
9995
+ if (!existing) return context.json({ error: { message: "Library not found." } }, 404);
9996
+ const force = new URL(context.req.url).searchParams.get("force") === "true";
9997
+ const pattern = libraryPathPrefixPattern(existing.path);
9998
+ if (!force) {
9999
+ const referenceCount = countLibraryReferences(existing, pattern);
10000
+ if (referenceCount > 0) {
10001
+ const noun = existing.type === "movies" ? referenceCount === 1 ? "movie" : "movies" : referenceCount === 1 ? "show" : "shows";
10002
+ return context.json({ error: {
10003
+ message: `Couldn't remove library — ${referenceCount} ${noun} reference it. Remove them first or use 'Force remove'.`,
10004
+ referenceCount
10005
+ } }, 409);
10006
+ }
10007
+ db.delete(libraries).where(eq(libraries.id, id)).run();
10008
+ return context.json({ success: true });
10009
+ }
10010
+ db.transaction((tx) => {
10011
+ if (existing.type === "movies") {
10012
+ const movieIds = tx.select({ id: movies.id }).from(movies).where(sql`${movies.filePath} LIKE ${pattern} ESCAPE '\\'`).all().map((row) => row.id);
10013
+ if (movieIds.length > 0) {
10014
+ tx.delete(watchProgress).where(inArray(watchProgress.movieId, movieIds)).run();
10015
+ tx.delete(movies).where(inArray(movies.id, movieIds)).run();
10016
+ }
10017
+ } else {
10018
+ const showIds = tx.select({ id: shows.id }).from(shows).where(sql`${shows.folderPath} LIKE ${pattern} ESCAPE '\\'`).all().map((row) => row.id);
10019
+ if (showIds.length > 0) {
10020
+ const episodeIds = tx.select({ id: episodes.id }).from(episodes).where(inArray(episodes.showId, showIds)).all().map((row) => row.id);
10021
+ if (episodeIds.length > 0) {
10022
+ tx.delete(watchProgress).where(inArray(watchProgress.episodeId, episodeIds)).run();
10023
+ tx.delete(episodes).where(inArray(episodes.id, episodeIds)).run();
10024
+ }
10025
+ tx.delete(seasons).where(inArray(seasons.showId, showIds)).run();
10026
+ tx.delete(shows).where(inArray(shows.id, showIds)).run();
10027
+ }
10028
+ }
10029
+ tx.delete(libraries).where(eq(libraries.id, id)).run();
10030
+ });
10031
+ return context.json({ success: true });
10032
+ });
10033
+ function countLibraryReferences(library, pattern) {
10034
+ if (library.type === "movies") {
10035
+ const [row] = db.select({ count: sql`count(*)` }).from(movies).where(sql`${movies.filePath} LIKE ${pattern} ESCAPE '\\'`).all();
10036
+ return row?.count ?? 0;
10037
+ }
10038
+ const [row] = db.select({ count: sql`count(*)` }).from(shows).where(sql`${shows.folderPath} LIKE ${pattern} ESCAPE '\\'`).all();
10039
+ return row?.count ?? 0;
10040
+ }
10041
+ settingsRoutes.get("/scan-schedule", async (context) => {
10042
+ const stored = await getSetting(scanScheduleSettingKey);
10043
+ const value = stored !== null && isScanScheduleValue(stored) ? stored : "off";
10044
+ const info = scheduler.getInfo();
10045
+ return context.json({
10046
+ nextRunAt: info.nextRunAt?.toISOString() ?? null,
10047
+ schedule: value
10048
+ });
10049
+ });
10050
+ settingsRoutes.put("/scan-schedule", async (context) => {
10051
+ let body;
10052
+ try {
10053
+ body = await context.req.json();
10054
+ } catch {
10055
+ return context.json({ error: { message: "Invalid request body." } }, 400);
10056
+ }
10057
+ const schedule = typeof body.schedule === "string" ? body.schedule.trim() : "";
10058
+ if (!isScanScheduleValue(schedule)) return context.json({ error: { message: "Scan schedule must be one of: off, 6h, 12h, 24h." } }, 400);
10059
+ await setSetting(scanScheduleSettingKey, schedule);
10060
+ scheduler.updateSchedule(schedule);
10061
+ const info = scheduler.getInfo();
10062
+ return context.json({
10063
+ nextRunAt: info.nextRunAt?.toISOString() ?? null,
10064
+ schedule
10065
+ });
10066
+ });
10067
+ settingsRoutes.get("/:key", async (context) => {
10068
+ const key = context.req.param("key");
10069
+ if (reservedKeys.has(key)) return context.json({ error: { message: `Use GET ${reservedKeyRoute[key]} for this key.` } }, 400);
10070
+ const value = await getSetting(key);
10071
+ if (value === null) return context.json({ value: null });
10072
+ return context.json({ value });
10073
+ });
10074
+ settingsRoutes.put("/:key", async (context) => {
10075
+ const key = context.req.param("key");
10076
+ if (reservedKeys.has(key)) return context.json({ error: { message: `Use PUT ${reservedKeyRoute[key]} for this key.` } }, 400);
10077
+ let body;
10078
+ try {
10079
+ body = await context.req.json();
10080
+ } catch {
10081
+ return context.json({ error: { message: "Invalid request body." } }, 400);
10082
+ }
10083
+ if (typeof body.value !== "string") return context.json({ error: { message: "Setting value must be a string." } }, 400);
10084
+ await setSetting(key, body.value);
10085
+ return context.json({ value: body.value });
7817
10086
  });
7818
10087
  //#endregion
7819
10088
  //#region src/api/routes/setup.ts
7820
10089
  const setupRoutes = new Hono();
7821
10090
  setupRoutes.get("/", async (context) => {
7822
- const config = getConfig();
10091
+ const apiKey = await getTmdbApiKey();
7823
10092
  const libraries$1 = await db.select().from(libraries);
7824
10093
  return context.json({
7825
- config: config ? { tmdbApiKey: config.tmdbApiKey } : null,
7826
- hasApiKey: config?.tmdbApiKey !== void 0 && config.tmdbApiKey !== "",
10094
+ config: apiKey !== null ? { tmdbApiKey: apiKey } : null,
10095
+ hasApiKey: apiKey !== null && apiKey !== "",
7827
10096
  libraries: libraries$1.map((library) => ({
7828
10097
  id: library.id,
7829
10098
  name: library.name,
@@ -7838,7 +10107,7 @@ setupRoutes.post("/complete", async (context) => {
7838
10107
  const body = await context.req.json();
7839
10108
  if (!body.tmdbApiKey) return context.json({ error: { message: "TMDB API key is required" } }, 400);
7840
10109
  if (!body.libraries || body.libraries.length === 0) return context.json({ error: { message: "At least one library is required" } }, 400);
7841
- await saveConfig({ tmdbApiKey: body.tmdbApiKey });
10110
+ await setTmdbApiKey(body.tmdbApiKey);
7842
10111
  await db.delete(libraries);
7843
10112
  const now = /* @__PURE__ */ new Date();
7844
10113
  for (const library of body.libraries) await db.insert(libraries).values({
@@ -7850,6 +10119,146 @@ setupRoutes.post("/complete", async (context) => {
7850
10119
  return context.json({ success: true });
7851
10120
  });
7852
10121
  //#endregion
10122
+ //#region src/api/routes/profiles.ts
10123
+ const profileRoutes = new Hono();
10124
+ profileRoutes.get("/", async (context) => {
10125
+ const profiles$1 = await db.select().from(profiles).orderBy(desc(profiles.createdAt));
10126
+ return context.json(profiles$1);
10127
+ });
10128
+ profileRoutes.get("/active", async (context) => {
10129
+ const profileId = context.get("profileId");
10130
+ const [profile] = await db.select().from(profiles).where(eq(profiles.id, profileId)).limit(1);
10131
+ if (!profile) return context.json({ error: { message: "Active profile not found" } }, 404);
10132
+ return context.json(profile);
10133
+ });
10134
+ profileRoutes.post("/", async (context) => {
10135
+ const body = await context.req.json();
10136
+ const name = body.name?.trim();
10137
+ const emoji = body.emoji?.trim();
10138
+ if (!name || !emoji) return context.json({ error: { message: "name and emoji are required" } }, 400);
10139
+ const now = /* @__PURE__ */ new Date();
10140
+ try {
10141
+ const [created] = await db.insert(profiles).values({
10142
+ name,
10143
+ emoji,
10144
+ createdAt: now
10145
+ }).returning();
10146
+ return context.json(created, 201);
10147
+ } catch (error) {
10148
+ const message = error instanceof Error && error.message.includes("UNIQUE") ? "A profile with that name already exists" : "Failed to create profile";
10149
+ return context.json({ error: { message } }, 400);
10150
+ }
10151
+ });
10152
+ profileRoutes.patch("/:id", async (context) => {
10153
+ const id = parseInt(context.req.param("id"), 10);
10154
+ if (isNaN(id)) return context.json({ error: { message: "Invalid profile id" } }, 400);
10155
+ const body = await context.req.json();
10156
+ const updates = {};
10157
+ if (typeof body.name === "string" && body.name.trim()) updates.name = body.name.trim();
10158
+ if (typeof body.emoji === "string" && body.emoji.trim()) updates.emoji = body.emoji.trim();
10159
+ if (Object.keys(updates).length === 0) return context.json({ error: { message: "Nothing to update" } }, 400);
10160
+ try {
10161
+ const [updated] = await db.update(profiles).set(updates).where(eq(profiles.id, id)).returning();
10162
+ if (!updated) return context.json({ error: { message: "Profile not found" } }, 404);
10163
+ return context.json(updated);
10164
+ } catch (error) {
10165
+ const message = error instanceof Error && error.message.includes("UNIQUE") ? "A profile with that name already exists" : "Failed to update profile";
10166
+ return context.json({ error: { message } }, 400);
10167
+ }
10168
+ });
10169
+ profileRoutes.delete("/:id", async (context) => {
10170
+ const id = parseInt(context.req.param("id"), 10);
10171
+ if (isNaN(id)) return context.json({ error: { message: "Invalid profile id" } }, 400);
10172
+ const all = await db.select({ id: profiles.id }).from(profiles);
10173
+ if (all.length <= 1) return context.json({ error: { message: "Cannot delete the last remaining profile" } }, 400);
10174
+ await db.delete(profiles).where(eq(profiles.id, id));
10175
+ if (context.get("profileId") === id) {
10176
+ const next = all.find((profile) => profile.id !== id);
10177
+ if (next) setProfileCookie(context, next.id);
10178
+ }
10179
+ return context.json({ success: true });
10180
+ });
10181
+ profileRoutes.post("/:id/activate", async (context) => {
10182
+ const id = parseInt(context.req.param("id"), 10);
10183
+ if (isNaN(id)) return context.json({ error: { message: "Invalid profile id" } }, 400);
10184
+ const [profile] = await db.select().from(profiles).where(eq(profiles.id, id)).limit(1);
10185
+ if (!profile) return context.json({ error: { message: "Profile not found" } }, 404);
10186
+ setProfileCookie(context, profile.id);
10187
+ return context.json(profile);
10188
+ });
10189
+ //#endregion
10190
+ //#region src/api/routes/favorites.ts
10191
+ const favoriteRoutes = new Hono();
10192
+ favoriteRoutes.get("/", async (context) => {
10193
+ const profileId = context.get("profileId");
10194
+ const movieFavorites = await db.select({
10195
+ createdAt: favorites.createdAt,
10196
+ movie: movies
10197
+ }).from(favorites).innerJoin(movies, eq(favorites.movieId, movies.id)).where(eq(favorites.profileId, profileId)).orderBy(desc(favorites.createdAt));
10198
+ const showFavorites = await db.select({
10199
+ createdAt: favorites.createdAt,
10200
+ show: shows
10201
+ }).from(favorites).innerJoin(shows, eq(favorites.showId, shows.id)).where(eq(favorites.profileId, profileId)).orderBy(desc(favorites.createdAt));
10202
+ const movies$1 = movieFavorites.map((row) => ({
10203
+ ...row,
10204
+ type: "movie"
10205
+ }));
10206
+ const shows$2 = showFavorites.map((row) => ({
10207
+ ...row,
10208
+ type: "show"
10209
+ }));
10210
+ const combined = [...movies$1, ...shows$2].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
10211
+ return context.json(combined);
10212
+ });
10213
+ favoriteRoutes.get("/status", async (context) => {
10214
+ const profileId = context.get("profileId");
10215
+ const movieIdParam = context.req.query("movieId");
10216
+ const showIdParam = context.req.query("showId");
10217
+ if (movieIdParam) {
10218
+ const movieId = parseInt(movieIdParam, 10);
10219
+ if (isNaN(movieId)) return context.json({ error: { message: "Invalid movie ID" } }, 400);
10220
+ const [favorite] = await db.select({ id: favorites.id }).from(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.movieId, movieId))).limit(1);
10221
+ return context.json({ favorite: !!favorite });
10222
+ }
10223
+ if (showIdParam) {
10224
+ const showId = parseInt(showIdParam, 10);
10225
+ if (isNaN(showId)) return context.json({ error: { message: "Invalid show ID" } }, 400);
10226
+ const [favorite] = await db.select({ id: favorites.id }).from(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.showId, showId))).limit(1);
10227
+ return context.json({ favorite: !!favorite });
10228
+ }
10229
+ return context.json({ error: { message: "movieId or showId required" } }, 400);
10230
+ });
10231
+ favoriteRoutes.post("/", async (context) => {
10232
+ const profileId = context.get("profileId");
10233
+ const body = await context.req.json();
10234
+ if (typeof body.movieId !== "number" && typeof body.showId !== "number" || typeof body.movieId === "number" && typeof body.showId === "number") return context.json({ error: { message: "Provide exactly one of movieId or showId" } }, 400);
10235
+ const now = /* @__PURE__ */ new Date();
10236
+ try {
10237
+ await db.insert(favorites).values({
10238
+ profileId,
10239
+ movieId: body.movieId ?? null,
10240
+ showId: body.showId ?? null,
10241
+ createdAt: now
10242
+ });
10243
+ } catch (error) {
10244
+ if (!(error instanceof Error) || !error.message.includes("UNIQUE")) throw error;
10245
+ }
10246
+ return context.json({ success: true });
10247
+ });
10248
+ favoriteRoutes.delete("/", async (context) => {
10249
+ const profileId = context.get("profileId");
10250
+ const body = await context.req.json();
10251
+ if (typeof body.movieId === "number") {
10252
+ await db.delete(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.movieId, body.movieId)));
10253
+ return context.json({ success: true });
10254
+ }
10255
+ if (typeof body.showId === "number") {
10256
+ await db.delete(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.showId, body.showId)));
10257
+ return context.json({ success: true });
10258
+ }
10259
+ return context.json({ error: { message: "movieId or showId required" } }, 400);
10260
+ });
10261
+ //#endregion
7853
10262
  //#region src/api/routes/shows.ts
7854
10263
  const showRoutes = new Hono();
7855
10264
  showRoutes.get("/", async (context) => {
@@ -7871,8 +10280,35 @@ showRoutes.get("/episodes/:id", async (context) => {
7871
10280
  const [episode] = await db.select().from(episodes).where(eq(episodes.id, id)).limit(1);
7872
10281
  if (!episode) return context.json({ error: { message: "Episode not found" } }, 404);
7873
10282
  const [show] = await db.select().from(shows).where(eq(shows.id, episode.showId)).limit(1);
10283
+ const subtitles$1 = (await db.select().from(subtitles).where(eq(subtitles.episodeId, id))).map((row) => ({
10284
+ id: row.id,
10285
+ displayLanguage: languageDisplayName(row.language),
10286
+ format: row.format,
10287
+ isSupported: row.format !== "ass",
10288
+ language: row.language
10289
+ }));
7874
10290
  return context.json({
7875
10291
  episode,
10292
+ show,
10293
+ subtitles: subtitles$1
10294
+ });
10295
+ });
10296
+ showRoutes.get("/:showId/next-episode", async (context) => {
10297
+ const showId = parseInt(context.req.param("showId"), 10);
10298
+ const afterEpisodeIdParam = context.req.query("afterEpisodeId");
10299
+ const afterEpisodeId = afterEpisodeIdParam ? parseInt(afterEpisodeIdParam, 10) : NaN;
10300
+ if (isNaN(showId) || isNaN(afterEpisodeId)) return context.json({ error: { message: "Provide a numeric showId and a numeric afterEpisodeId query parameter." } }, 400);
10301
+ const [currentEpisode] = await db.select().from(episodes).where(and(eq(episodes.id, afterEpisodeId), eq(episodes.showId, showId))).limit(1);
10302
+ if (!currentEpisode) return context.json({ error: { message: "That episode does not belong to this show. Double-check the showId and afterEpisodeId." } }, 404);
10303
+ const nextEpisode = (await db.select().from(episodes).where(eq(episodes.showId, showId)).orderBy(episodes.seasonNumber, episodes.episodeNumber)).find((candidate) => {
10304
+ if (candidate.seasonNumber > currentEpisode.seasonNumber) return true;
10305
+ if (candidate.seasonNumber < currentEpisode.seasonNumber) return false;
10306
+ return candidate.episodeNumber > currentEpisode.episodeNumber;
10307
+ });
10308
+ if (!nextEpisode) return context.json({ error: { message: "No more episodes after this one." } }, 404);
10309
+ const [show] = await db.select().from(shows).where(eq(shows.id, showId)).limit(1);
10310
+ return context.json({
10311
+ episode: nextEpisode,
7876
10312
  show
7877
10313
  });
7878
10314
  });
@@ -7952,23 +10388,341 @@ streamRoutes.get("/:id", async (context) => {
7952
10388
  } });
7953
10389
  });
7954
10390
  //#endregion
10391
+ //#region src/api/routes/subtitles.ts
10392
+ const subtitleRoutes = new Hono();
10393
+ const conversionFailedMessage = "Couldn't convert subtitle file. Check it's a valid SRT.";
10394
+ const outsideLibraryMessage = "Subtitle is outside the configured library paths. Rescan your libraries.";
10395
+ function subtitleIsInsideALibrary(subtitleFilePath, libraryPaths) {
10396
+ const resolvedSubtitle = path.resolve(subtitleFilePath);
10397
+ for (const libraryPath of libraryPaths) {
10398
+ const resolvedLibrary = path.resolve(libraryPath);
10399
+ const libraryWithSeparator = resolvedLibrary.endsWith(path.sep) ? resolvedLibrary : `${resolvedLibrary}${path.sep}`;
10400
+ if (resolvedSubtitle.startsWith(libraryWithSeparator)) return true;
10401
+ }
10402
+ return false;
10403
+ }
10404
+ subtitleRoutes.get("/:id", async (context) => {
10405
+ const id = parseInt(context.req.param("id"), 10);
10406
+ if (isNaN(id)) return context.json({ error: { message: "Invalid subtitle ID" } }, 400);
10407
+ const [subtitle] = await db.select().from(subtitles).where(eq(subtitles.id, id)).limit(1);
10408
+ if (!subtitle) return context.json({ error: { message: "Subtitle not found" } }, 404);
10409
+ const libraryPaths = (await db.select({ path: libraries.path }).from(libraries)).map((library) => library.path);
10410
+ if (!subtitleIsInsideALibrary(subtitle.filePath, libraryPaths)) {
10411
+ log.warn(`Refusing to serve subtitle ${subtitle.id} — ${subtitle.filePath} is not inside any configured library. Rescan the library to clean up stale rows.`);
10412
+ return context.json({ error: { message: outsideLibraryMessage } }, 404);
10413
+ }
10414
+ if (subtitle.format === "ass") return context.text("ASS subtitles aren't supported by the web player. Convert the file to .srt or .vtt and rescan.", 415);
10415
+ let raw;
10416
+ try {
10417
+ raw = await readFile(subtitle.filePath, "utf-8");
10418
+ } catch (error) {
10419
+ log.warn(`Couldn't read subtitle file ${subtitle.filePath} - ${error instanceof Error ? error.message : String(error)}. Make sure the file still exists at that path and rescan the library.`);
10420
+ return context.text(conversionFailedMessage, 500);
10421
+ }
10422
+ if (subtitle.format === "vtt") return new Response(raw, {
10423
+ status: 200,
10424
+ headers: {
10425
+ "Cache-Control": "public, max-age=3600",
10426
+ "Content-Type": "text/vtt; charset=utf-8"
10427
+ }
10428
+ });
10429
+ let converted;
10430
+ try {
10431
+ converted = convertSrtToVtt(raw);
10432
+ } catch (error) {
10433
+ log.warn(`Couldn't convert subtitle file ${subtitle.filePath} to WebVTT - ${error instanceof Error ? error.message : String(error)}. Convert the file to UTF-8 .vtt and rescan.`);
10434
+ return context.text(conversionFailedMessage, 500);
10435
+ }
10436
+ return new Response(converted, {
10437
+ status: 200,
10438
+ headers: {
10439
+ "Cache-Control": "public, max-age=3600",
10440
+ "Content-Type": "text/vtt; charset=utf-8"
10441
+ }
10442
+ });
10443
+ });
10444
+ //#endregion
10445
+ //#region src/api/routes/transcode.ts
10446
+ const logger = {
10447
+ error: (...args) => console.error("[transcode]", ...args),
10448
+ info: (...args) => console.info("[transcode]", ...args),
10449
+ warn: (...args) => console.warn("[transcode]", ...args)
10450
+ };
10451
+ const reapIdleMs = 1800 * 1e3;
10452
+ const reaperIntervalMs = 300 * 1e3;
10453
+ const sessions = /* @__PURE__ */ new Map();
10454
+ function sessionKey(fileId, profileId) {
10455
+ return `${fileId}:${profileId}`;
10456
+ }
10457
+ function safeDirectoryName(fileId, profileId) {
10458
+ return `${fileId}__${profileId}`;
10459
+ }
10460
+ function getTranscodeRoot() {
10461
+ return path.join(os.tmpdir(), "jukebox-transcode");
10462
+ }
10463
+ function waitForPlaylist(playlistPath, timeoutMs) {
10464
+ return new Promise((resolve, reject) => {
10465
+ const startedAt = Date.now();
10466
+ const check = () => {
10467
+ if (existsSync(playlistPath)) {
10468
+ resolve();
10469
+ return;
10470
+ }
10471
+ if (Date.now() - startedAt > timeoutMs) {
10472
+ reject(/* @__PURE__ */ new Error("Couldn't prepare this file for casting. Check that ffmpeg is installed."));
10473
+ return;
10474
+ }
10475
+ setTimeout(check, 200);
10476
+ };
10477
+ check();
10478
+ });
10479
+ }
10480
+ function startTranscode({ fileId, filePath, profileId, spawnImplementation }) {
10481
+ const key = sessionKey(fileId, profileId);
10482
+ const existing = sessions.get(key);
10483
+ if (existing) {
10484
+ existing.lastAccessedAt = Date.now();
10485
+ return existing;
10486
+ }
10487
+ const tempDir = path.join(getTranscodeRoot(), safeDirectoryName(fileId, profileId));
10488
+ mkdirSync(tempDir, { recursive: true });
10489
+ const playlistPath = path.join(tempDir, "index.m3u8");
10490
+ const args = [
10491
+ "-i",
10492
+ filePath,
10493
+ "-c:v",
10494
+ "copy",
10495
+ "-c:a",
10496
+ "aac",
10497
+ "-ac",
10498
+ "2",
10499
+ "-f",
10500
+ "hls",
10501
+ "-hls_time",
10502
+ "6",
10503
+ "-hls_list_size",
10504
+ "0",
10505
+ "-hls_segment_filename",
10506
+ path.join(tempDir, "segment-%03d.ts"),
10507
+ playlistPath
10508
+ ];
10509
+ const spawner = spawnImplementation ?? spawn;
10510
+ let ffmpegProcess;
10511
+ try {
10512
+ ffmpegProcess = spawner("ffmpeg", args, { stdio: [
10513
+ "ignore",
10514
+ "pipe",
10515
+ "pipe"
10516
+ ] });
10517
+ } catch (error) {
10518
+ logger.error(`Failed to spawn ffmpeg for ${fileId}:`, error);
10519
+ throw new Error("Couldn't prepare this file for casting. Check that ffmpeg is installed.", { cause: error });
10520
+ }
10521
+ ffmpegProcess.on("error", (error) => {
10522
+ logger.error(`ffmpeg process error for ${fileId}:`, error);
10523
+ });
10524
+ ffmpegProcess.on("exit", (code) => {
10525
+ logger.info(`ffmpeg exited for ${fileId} with code ${code}`);
10526
+ });
10527
+ const readyPromise = waitForPlaylist(playlistPath, 3e4);
10528
+ const session = {
10529
+ fileId,
10530
+ ffmpegProcess,
10531
+ lastAccessedAt: Date.now(),
10532
+ playlistPath,
10533
+ tempDir,
10534
+ readyPromise
10535
+ };
10536
+ sessions.set(key, session);
10537
+ return session;
10538
+ }
10539
+ function stopSession(key) {
10540
+ const session = sessions.get(key);
10541
+ if (!session) return;
10542
+ if (session.ffmpegProcess && session.ffmpegProcess.exitCode === null) try {
10543
+ session.ffmpegProcess.kill("SIGTERM");
10544
+ } catch (error) {
10545
+ logger.warn(`Failed to kill ffmpeg for ${session.fileId}:`, error);
10546
+ }
10547
+ try {
10548
+ rmSync(session.tempDir, {
10549
+ recursive: true,
10550
+ force: true
10551
+ });
10552
+ } catch (error) {
10553
+ logger.warn(`Failed to remove temp dir ${session.tempDir}:`, error);
10554
+ }
10555
+ sessions.delete(key);
10556
+ }
10557
+ function reapIdleSessions(now = Date.now()) {
10558
+ for (const [key, session] of sessions.entries()) if (now - session.lastAccessedAt > reapIdleMs) {
10559
+ logger.info(`Reaping idle transcode session ${key}`);
10560
+ stopSession(key);
10561
+ }
10562
+ }
10563
+ if (typeof setInterval !== "undefined" && process.env.NODE_ENV !== "test") setInterval(() => reapIdleSessions(), reaperIntervalMs).unref?.();
10564
+ async function resolveFile(fileId) {
10565
+ if (fileId.startsWith("episode-")) {
10566
+ const episodeId = parseInt(fileId.slice(8), 10);
10567
+ if (isNaN(episodeId)) return null;
10568
+ const [episode] = await db.select().from(episodes).where(eq(episodes.id, episodeId)).limit(1);
10569
+ if (!episode) return null;
10570
+ return { filePath: episode.filePath };
10571
+ }
10572
+ if (fileId.startsWith("movie-")) {
10573
+ const movieId = parseInt(fileId.slice(6), 10);
10574
+ if (isNaN(movieId)) return null;
10575
+ const [movie] = await db.select().from(movies).where(eq(movies.id, movieId)).limit(1);
10576
+ if (!movie) return null;
10577
+ return { filePath: movie.filePath };
10578
+ }
10579
+ return null;
10580
+ }
10581
+ const transcodeRoutes = new Hono();
10582
+ transcodeRoutes.get("/:fileId/index.m3u8", async (context) => {
10583
+ const fileId = context.req.param("fileId");
10584
+ const profileIdRaw = context.req.header("x-jukebox-profile-id") ?? "0";
10585
+ const profileId = parseInt(profileIdRaw, 10);
10586
+ const resolved = await resolveFile(fileId);
10587
+ if (!resolved) return context.json({ error: { message: "File not found" } }, 404);
10588
+ if (!existsSync(resolved.filePath)) return context.json({ error: { message: "Video file not found" } }, 404);
10589
+ try {
10590
+ const session = startTranscode({
10591
+ fileId,
10592
+ filePath: resolved.filePath,
10593
+ profileId: isNaN(profileId) ? 0 : profileId
10594
+ });
10595
+ session.lastAccessedAt = Date.now();
10596
+ await session.readyPromise;
10597
+ const playlistStream = createReadStream(session.playlistPath);
10598
+ const webStream = Readable.toWeb(playlistStream);
10599
+ return new Response(webStream, { headers: {
10600
+ "Content-Type": "application/vnd.apple.mpegurl",
10601
+ "Cache-Control": "no-store"
10602
+ } });
10603
+ } catch (error) {
10604
+ const message = error instanceof Error ? error.message : "Couldn't prepare this file for casting. Check that ffmpeg is installed.";
10605
+ return context.json({ error: { message } }, 500);
10606
+ }
10607
+ });
10608
+ transcodeRoutes.get("/:fileId/:segment", async (context) => {
10609
+ const fileId = context.req.param("fileId");
10610
+ const segment = context.req.param("segment");
10611
+ const profileIdRaw = context.req.header("x-jukebox-profile-id") ?? "0";
10612
+ const profileId = parseInt(profileIdRaw, 10);
10613
+ const key = sessionKey(fileId, isNaN(profileId) ? 0 : profileId);
10614
+ const session = sessions.get(key);
10615
+ if (!session) return context.json({ error: { message: "Transcode session not found" } }, 404);
10616
+ if (!/^segment-\d+\.ts$/.test(segment)) return context.json({ error: { message: "Invalid segment" } }, 400);
10617
+ const segmentPath = path.join(session.tempDir, segment);
10618
+ const startedAt = Date.now();
10619
+ while (!existsSync(segmentPath) && Date.now() - startedAt < 15e3) await new Promise((resolve) => setTimeout(resolve, 200));
10620
+ if (!existsSync(segmentPath)) return context.json({ error: { message: "Segment not ready" } }, 404);
10621
+ session.lastAccessedAt = Date.now();
10622
+ const size = statSync(segmentPath).size;
10623
+ const nodeStream = createReadStream(segmentPath);
10624
+ const webStream = Readable.toWeb(nodeStream);
10625
+ return new Response(webStream, { headers: {
10626
+ "Content-Type": "video/mp2t",
10627
+ "Content-Length": String(size),
10628
+ "Cache-Control": "no-store"
10629
+ } });
10630
+ });
10631
+ try {
10632
+ const root = getTranscodeRoot();
10633
+ if (existsSync(root)) for (const entry of readdirSync(root)) rmSync(path.join(root, entry), {
10634
+ recursive: true,
10635
+ force: true
10636
+ });
10637
+ } catch (error) {
10638
+ logger.warn("Failed to clear transcode root on boot:", error);
10639
+ }
10640
+ //#endregion
10641
+ //#region src/lib/watched.ts
10642
+ /**
10643
+ * Shared "watched" threshold used across the server and client.
10644
+ *
10645
+ * An episode (or movie) is considered complete once the profile has
10646
+ * progressed at least this fraction of the total duration. Kept in a single
10647
+ * module so server endpoints ("Up Next", next-episode) and client overlays
10648
+ * ("Up Next" countdown) can't drift.
10649
+ */
10650
+ const watchedThreshold = .9;
10651
+ //#endregion
10652
+ //#region src/api/routes/up-next.ts
10653
+ const upNextRoutes = new Hono();
10654
+ upNextRoutes.get("/", async (context) => {
10655
+ const profileId = context.get("profileId");
10656
+ const episodeProgressRows = await db.select({
10657
+ currentTime: watchProgress.currentTime,
10658
+ duration: watchProgress.duration,
10659
+ episode: episodes,
10660
+ updatedAt: watchProgress.updatedAt
10661
+ }).from(watchProgress).innerJoin(episodes, eq(watchProgress.episodeId, episodes.id)).where(eq(watchProgress.profileId, profileId)).orderBy(desc(watchProgress.updatedAt));
10662
+ const latestRowByShow = /* @__PURE__ */ new Map();
10663
+ for (const row of episodeProgressRows) if (!latestRowByShow.has(row.episode.showId)) latestRowByShow.set(row.episode.showId, row);
10664
+ const items = [];
10665
+ for (const [showId, row] of latestRowByShow.entries()) {
10666
+ if (!((row.duration && row.duration > 0 ? row.currentTime / row.duration : 0) >= .9)) continue;
10667
+ const laterEpisodes = (await db.select().from(episodes).where(eq(episodes.showId, showId)).orderBy(episodes.seasonNumber, episodes.episodeNumber)).filter((candidate) => {
10668
+ if (candidate.seasonNumber > row.episode.seasonNumber) return true;
10669
+ if (candidate.seasonNumber < row.episode.seasonNumber) return false;
10670
+ return candidate.episodeNumber > row.episode.episodeNumber;
10671
+ });
10672
+ if (laterEpisodes.length === 0) continue;
10673
+ const laterIds = new Set(laterEpisodes.map((candidate) => candidate.id));
10674
+ const laterProgress = episodeProgressRows.filter((progressRow) => progressRow.episode.id !== row.episode.id && laterIds.has(progressRow.episode.id));
10675
+ const progressByEpisodeId = /* @__PURE__ */ new Map();
10676
+ for (const progressRow of laterProgress) progressByEpisodeId.set(progressRow.episode.id, {
10677
+ currentTime: progressRow.currentTime,
10678
+ duration: progressRow.duration
10679
+ });
10680
+ const nextEpisode = laterEpisodes.find((candidate) => {
10681
+ const progress = progressByEpisodeId.get(candidate.id);
10682
+ if (!progress) return true;
10683
+ if (!progress.duration || progress.duration <= 0) return true;
10684
+ return progress.currentTime / progress.duration < watchedThreshold;
10685
+ });
10686
+ if (!nextEpisode) continue;
10687
+ const [show] = await db.select().from(shows).where(eq(shows.id, showId)).limit(1);
10688
+ if (!show) continue;
10689
+ items.push({
10690
+ episode: nextEpisode,
10691
+ show,
10692
+ lastWatchedAt: row.updatedAt.toISOString()
10693
+ });
10694
+ }
10695
+ items.sort((a, b) => new Date(b.lastWatchedAt).getTime() - new Date(a.lastWatchedAt).getTime());
10696
+ return context.json(items.slice(0, 20));
10697
+ });
10698
+ //#endregion
7955
10699
  //#region src/api/index.ts
7956
10700
  const app = new Hono();
7957
- app.use("*", logger());
10701
+ app.use("*", logger$1());
10702
+ app.use("/api/*", authMiddleware);
10703
+ app.use("/api/*", profileMiddleware);
7958
10704
  app.onError((error, context) => {
7959
10705
  console.error("Unhandled error:", error);
7960
10706
  const message = error instanceof Error ? error.message : "An unexpected error occurred";
7961
10707
  return context.json({ error: { message } }, 500);
7962
10708
  });
10709
+ app.route("/api/auth", authRoutes);
7963
10710
  app.route("/api/hello", helloRoutes);
7964
10711
  app.route("/api/library/shows", showRoutes);
10712
+ app.route("/api/library/up-next", upNextRoutes);
7965
10713
  app.route("/api/library", libraryRoutes);
7966
10714
  app.route("/api/progress/episode", episodeProgressRoutes);
7967
10715
  app.route("/api/progress", progressRoutes);
7968
10716
  app.route("/api/scan", scanRoutes);
10717
+ app.route("/api/search", searchRoutes);
10718
+ app.route("/api/profiles", profileRoutes);
10719
+ app.route("/api/favorites", favoriteRoutes);
10720
+ app.route("/api/settings", settingsRoutes);
7969
10721
  app.route("/api/setup", setupRoutes);
7970
10722
  app.route("/api/stream/episode", episodeStreamRoutes);
7971
10723
  app.route("/api/stream", streamRoutes);
10724
+ app.route("/api/subtitles", subtitleRoutes);
10725
+ app.route("/api/transcode", transcodeRoutes);
7972
10726
  app.get("/api", (c) => c.json({ message: "Jukebox API" }));
7973
10727
  const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
7974
10728
  const clientDir = path.resolve(__dirname$1, "../client");
@@ -8035,7 +10789,8 @@ function readPackageVersion() {
8035
10789
  const version = readPackageVersion();
8036
10790
  let viteServer = null;
8037
10791
  if (isDevelopment) {
8038
- viteServer = await createServer$1({
10792
+ const { createServer } = await import("vite");
10793
+ viteServer = await createServer({
8039
10794
  configFile: "./vite.config.ts",
8040
10795
  server: {
8041
10796
  port: vitePort,
@@ -8045,6 +10800,8 @@ if (isDevelopment) {
8045
10800
  await viteServer.listen();
8046
10801
  setupViteProxy(vitePort);
8047
10802
  } else setupStaticServing();
10803
+ await scanManager.recoverInterruptedJobs();
10804
+ await scheduler.start();
8048
10805
  const server = serve({
8049
10806
  fetch: app.fetch,
8050
10807
  port
@@ -8070,6 +10827,7 @@ function printWelcome(boundPort) {
8070
10827
  function shutdown(signal) {
8071
10828
  console.log(`\nReceived ${signal}, shutting down...`);
8072
10829
  setTimeout(() => process.exit(1), 2e3).unref();
10830
+ scheduler.stop();
8073
10831
  server.close(() => {
8074
10832
  if (viteServer) viteServer.close().finally(() => process.exit(0));
8075
10833
  else process.exit(0);