jukebox-media-server 0.1.0 → 0.2.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,26 @@
1
+ import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "fs";
2
+ import path, { basename, extname, join } from "path";
3
+ import { fileURLToPath } from "url";
1
4
  import { createServer } from "http";
2
5
  import { Http2ServerRequest, constants } from "http2";
3
6
  import { Readable } from "stream";
4
- import crypto$1 from "crypto";
5
- import { createServer as createServer$1 } from "vite";
6
- import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from "fs";
7
- import path, { basename, extname, join } from "path";
8
- import { fileURLToPath } from "url";
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
12
  import { readdir, stat, writeFile } from "fs/promises";
14
- import { homedir } from "os";
13
+ import os, { homedir } from "os";
14
+ import { promisify } from "util";
15
+ import { spawn } from "child_process";
15
16
  //#region \0rolldown/runtime.js
17
+ var __create = Object.create;
16
18
  var __defProp = Object.defineProperty;
19
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
20
+ var __getOwnPropNames = Object.getOwnPropertyNames;
21
+ var __getProtoOf = Object.getPrototypeOf;
22
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
23
+ var __commonJSMin = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
17
24
  var __exportAll = (all, no_symbols) => {
18
25
  let target = {};
19
26
  for (var name in all) __defProp(target, name, {
@@ -23,6 +30,20 @@ var __exportAll = (all, no_symbols) => {
23
30
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
24
31
  return target;
25
32
  };
33
+ var __copyProps = (to, from, except, desc) => {
34
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
35
+ key = keys[i];
36
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
37
+ get: ((k) => from[k]).bind(null, key),
38
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
39
+ });
40
+ }
41
+ return to;
42
+ };
43
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
44
+ value: mod,
45
+ enumerable: true
46
+ }) : target, mod));
26
47
  //#endregion
27
48
  //#region node_modules/@hono/node-server/dist/index.mjs
28
49
  var RequestError = class extends Error {
@@ -2500,7 +2521,7 @@ var colorStatus = async (status) => {
2500
2521
  async function log(fn, prefix, method, path, status = 0, elapsed) {
2501
2522
  fn(prefix === "<--" ? `${prefix} ${method} ${path}` : `${prefix} ${method} ${path} ${await colorStatus(status)} ${elapsed}`);
2502
2523
  }
2503
- var logger = (fn = console.log) => {
2524
+ var logger$1 = (fn = console.log) => {
2504
2525
  return async function logger2(c, next) {
2505
2526
  const { method, url } = c.req;
2506
2527
  const path = url.slice(url.indexOf("/", 8));
@@ -3719,6 +3740,109 @@ function mapRelationalRow(tablesConfig, tableConfig, row, buildQueryResultSelect
3719
3740
  return result;
3720
3741
  }
3721
3742
  //#endregion
3743
+ //#region node_modules/hono/dist/helper/factory/index.js
3744
+ var createMiddleware = (middleware) => middleware;
3745
+ //#endregion
3746
+ //#region node_modules/hono/dist/utils/cookie.js
3747
+ var validCookieNameRegEx = /^[\w!#$%&'*.^`|~+-]+$/;
3748
+ var validCookieValueRegEx = /^[ !#-:<-[\]-~]*$/;
3749
+ var parse = (cookie, name) => {
3750
+ if (name && cookie.indexOf(name) === -1) return {};
3751
+ const pairs = cookie.trim().split(";");
3752
+ const parsedCookie = {};
3753
+ for (let pairStr of pairs) {
3754
+ pairStr = pairStr.trim();
3755
+ const valueStartPos = pairStr.indexOf("=");
3756
+ if (valueStartPos === -1) continue;
3757
+ const cookieName = pairStr.substring(0, valueStartPos).trim();
3758
+ if (name && name !== cookieName || !validCookieNameRegEx.test(cookieName)) continue;
3759
+ let cookieValue = pairStr.substring(valueStartPos + 1).trim();
3760
+ if (cookieValue.startsWith("\"") && cookieValue.endsWith("\"")) cookieValue = cookieValue.slice(1, -1);
3761
+ if (validCookieValueRegEx.test(cookieValue)) {
3762
+ parsedCookie[cookieName] = cookieValue.indexOf("%") !== -1 ? tryDecode(cookieValue, decodeURIComponent_) : cookieValue;
3763
+ if (name) break;
3764
+ }
3765
+ }
3766
+ return parsedCookie;
3767
+ };
3768
+ var _serialize = (name, value, opt = {}) => {
3769
+ let cookie = `${name}=${value}`;
3770
+ if (name.startsWith("__Secure-") && !opt.secure) throw new Error("__Secure- Cookie must have Secure attributes");
3771
+ if (name.startsWith("__Host-")) {
3772
+ if (!opt.secure) throw new Error("__Host- Cookie must have Secure attributes");
3773
+ if (opt.path !== "/") throw new Error("__Host- Cookie must have Path attributes with \"/\"");
3774
+ if (opt.domain) throw new Error("__Host- Cookie must not have Domain attributes");
3775
+ }
3776
+ if (opt && typeof opt.maxAge === "number" && opt.maxAge >= 0) {
3777
+ if (opt.maxAge > 3456e4) throw new Error("Cookies Max-Age SHOULD NOT be greater than 400 days (34560000 seconds) in duration.");
3778
+ cookie += `; Max-Age=${opt.maxAge | 0}`;
3779
+ }
3780
+ if (opt.domain && opt.prefix !== "host") cookie += `; Domain=${opt.domain}`;
3781
+ if (opt.path) cookie += `; Path=${opt.path}`;
3782
+ if (opt.expires) {
3783
+ if (opt.expires.getTime() - Date.now() > 3456e7) throw new Error("Cookies Expires SHOULD NOT be greater than 400 days (34560000 seconds) in the future.");
3784
+ cookie += `; Expires=${opt.expires.toUTCString()}`;
3785
+ }
3786
+ if (opt.httpOnly) cookie += "; HttpOnly";
3787
+ if (opt.secure) cookie += "; Secure";
3788
+ if (opt.sameSite) cookie += `; SameSite=${opt.sameSite.charAt(0).toUpperCase() + opt.sameSite.slice(1)}`;
3789
+ if (opt.priority) cookie += `; Priority=${opt.priority.charAt(0).toUpperCase() + opt.priority.slice(1)}`;
3790
+ if (opt.partitioned) {
3791
+ if (!opt.secure) throw new Error("Partitioned Cookie must have Secure attributes");
3792
+ cookie += "; Partitioned";
3793
+ }
3794
+ return cookie;
3795
+ };
3796
+ var serialize = (name, value, opt) => {
3797
+ value = encodeURIComponent(value);
3798
+ return _serialize(name, value, opt);
3799
+ };
3800
+ //#endregion
3801
+ //#region node_modules/hono/dist/helper/cookie/index.js
3802
+ var getCookie = (c, key, prefix) => {
3803
+ const cookie = c.req.raw.headers.get("Cookie");
3804
+ if (typeof key === "string") {
3805
+ if (!cookie) return;
3806
+ let finalKey = key;
3807
+ if (prefix === "secure") finalKey = "__Secure-" + key;
3808
+ else if (prefix === "host") finalKey = "__Host-" + key;
3809
+ return parse(cookie, finalKey)[finalKey];
3810
+ }
3811
+ if (!cookie) return {};
3812
+ return parse(cookie);
3813
+ };
3814
+ var generateCookie = (name, value, opt) => {
3815
+ let cookie;
3816
+ if (opt?.prefix === "secure") cookie = serialize("__Secure-" + name, value, {
3817
+ path: "/",
3818
+ ...opt,
3819
+ secure: true
3820
+ });
3821
+ else if (opt?.prefix === "host") cookie = serialize("__Host-" + name, value, {
3822
+ ...opt,
3823
+ path: "/",
3824
+ secure: true,
3825
+ domain: void 0
3826
+ });
3827
+ else cookie = serialize(name, value, {
3828
+ path: "/",
3829
+ ...opt
3830
+ });
3831
+ return cookie;
3832
+ };
3833
+ var setCookie = (c, name, value, opt) => {
3834
+ const cookie = generateCookie(name, value, opt);
3835
+ c.header("Set-Cookie", cookie, { append: true });
3836
+ };
3837
+ var deleteCookie = (c, name, opt) => {
3838
+ const deletedCookie = getCookie(c, name, opt?.prefix);
3839
+ setCookie(c, name, "", {
3840
+ ...opt,
3841
+ maxAge: 0
3842
+ });
3843
+ return deletedCookie;
3844
+ };
3845
+ //#endregion
3722
3846
  //#region node_modules/drizzle-orm/selection-proxy.js
3723
3847
  var SelectionProxyHandler = class SelectionProxyHandler {
3724
3848
  static [entityKind] = "SelectionProxyHandler";
@@ -4269,6 +4393,55 @@ const sqliteTable = (name, columns, extraConfig) => {
4269
4393
  return sqliteTableBase(name, columns, extraConfig);
4270
4394
  };
4271
4395
  //#endregion
4396
+ //#region node_modules/drizzle-orm/sqlite-core/indexes.js
4397
+ var IndexBuilderOn = class {
4398
+ constructor(name, unique) {
4399
+ this.name = name;
4400
+ this.unique = unique;
4401
+ }
4402
+ static [entityKind] = "SQLiteIndexBuilderOn";
4403
+ on(...columns) {
4404
+ return new IndexBuilder(this.name, columns, this.unique);
4405
+ }
4406
+ };
4407
+ var IndexBuilder = class {
4408
+ static [entityKind] = "SQLiteIndexBuilder";
4409
+ /** @internal */
4410
+ config;
4411
+ constructor(name, columns, unique) {
4412
+ this.config = {
4413
+ name,
4414
+ columns,
4415
+ unique,
4416
+ where: void 0
4417
+ };
4418
+ }
4419
+ /**
4420
+ * Condition for partial index.
4421
+ */
4422
+ where(condition) {
4423
+ this.config.where = condition;
4424
+ return this;
4425
+ }
4426
+ /** @internal */
4427
+ build(table) {
4428
+ return new Index(this.config, table);
4429
+ }
4430
+ };
4431
+ var Index = class {
4432
+ static [entityKind] = "SQLiteIndex";
4433
+ config;
4434
+ constructor(config, table) {
4435
+ this.config = {
4436
+ ...config,
4437
+ table
4438
+ };
4439
+ }
4440
+ };
4441
+ function uniqueIndex(name) {
4442
+ return new IndexBuilderOn(name, true);
4443
+ }
4444
+ //#endregion
4272
4445
  //#region node_modules/drizzle-orm/sqlite-core/utils.js
4273
4446
  function extractUsedTable(table) {
4274
4447
  if (is(table, SQLiteTable)) return [`${table[Table.Symbol.BaseName]}`];
@@ -6633,13 +6806,23 @@ async function saveConfig(config) {
6633
6806
  //#endregion
6634
6807
  //#region src/database/schema.ts
6635
6808
  var schema_exports = /* @__PURE__ */ __exportAll({
6809
+ authConfig: () => authConfig,
6636
6810
  episodes: () => episodes,
6811
+ favorites: () => favorites,
6637
6812
  libraries: () => libraries,
6638
6813
  movies: () => movies,
6814
+ profiles: () => profiles,
6639
6815
  seasons: () => seasons,
6816
+ sessions: () => sessions$1,
6640
6817
  shows: () => shows,
6641
6818
  watchProgress: () => watchProgress
6642
6819
  });
6820
+ const profiles = sqliteTable("profiles", {
6821
+ id: integer("id").primaryKey({ autoIncrement: true }),
6822
+ name: text("name").notNull().unique(),
6823
+ emoji: text("emoji").notNull(),
6824
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull()
6825
+ });
6643
6826
  const libraries = sqliteTable("libraries", {
6644
6827
  id: integer("id").primaryKey({ autoIncrement: true }),
6645
6828
  name: text("name").notNull(),
@@ -6709,23 +6892,600 @@ const episodes = sqliteTable("episodes", {
6709
6892
  });
6710
6893
  const watchProgress = sqliteTable("watch_progress", {
6711
6894
  id: integer("id").primaryKey({ autoIncrement: true }),
6895
+ profileId: integer("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
6712
6896
  movieId: integer("movie_id").references(() => movies.id),
6713
6897
  episodeId: integer("episode_id").references(() => episodes.id),
6714
6898
  currentTime: integer("current_time").notNull(),
6715
6899
  duration: integer("duration"),
6716
6900
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull()
6901
+ }, (table) => [uniqueIndex("watch_progress_profile_movie_idx").on(table.profileId, table.movieId), uniqueIndex("watch_progress_profile_episode_idx").on(table.profileId, table.episodeId)]);
6902
+ const favorites = sqliteTable("favorites", {
6903
+ id: integer("id").primaryKey({ autoIncrement: true }),
6904
+ profileId: integer("profile_id").notNull().references(() => profiles.id, { onDelete: "cascade" }),
6905
+ movieId: integer("movie_id").references(() => movies.id, { onDelete: "cascade" }),
6906
+ showId: integer("show_id").references(() => shows.id, { onDelete: "cascade" }),
6907
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull()
6908
+ }, (table) => [uniqueIndex("favorites_profile_movie_idx").on(table.profileId, table.movieId), uniqueIndex("favorites_profile_show_idx").on(table.profileId, table.showId)]);
6909
+ const authConfig = sqliteTable("auth_config", {
6910
+ id: integer("id").primaryKey(),
6911
+ passwordHash: text("password_hash"),
6912
+ updatedAt: integer("updated_at").notNull()
6913
+ });
6914
+ const sessions$1 = sqliteTable("sessions", {
6915
+ id: text("id").primaryKey(),
6916
+ createdAt: integer("created_at").notNull(),
6917
+ expiresAt: integer("expires_at").notNull(),
6918
+ lastSeenAt: integer("last_seen_at").notNull(),
6919
+ userAgent: text("user_agent")
6717
6920
  });
6718
6921
  //#endregion
6719
6922
  //#region src/database/index.ts
6720
6923
  ensureConfigDirectory();
6721
6924
  const sqlite = new Database(databasePath);
6722
6925
  const db = drizzle(sqlite, { schema: schema_exports });
6723
- const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
6724
- migrate(db, { migrationsFolder: path.resolve(__dirname$1, "../../drizzle") });
6926
+ const __dirname$2 = path.dirname(fileURLToPath(import.meta.url));
6927
+ migrate(db, { migrationsFolder: path.resolve(__dirname$2, "../../drizzle") });
6928
+ //#endregion
6929
+ //#region node_modules/tiny-invariant/dist/esm/tiny-invariant.js
6930
+ var import_dayjs_min = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
6931
+ (function(t, e) {
6932
+ "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();
6933
+ })(exports, (function() {
6934
+ "use strict";
6935
+ 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 = {
6936
+ name: "en",
6937
+ weekdays: "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),
6938
+ months: "January_February_March_April_May_June_July_August_September_October_November_December".split("_"),
6939
+ ordinal: function(t) {
6940
+ var e = [
6941
+ "th",
6942
+ "st",
6943
+ "nd",
6944
+ "rd"
6945
+ ], n = t % 100;
6946
+ return "[" + t + (e[(n - 20) % 10] || e[n] || e[0]) + "]";
6947
+ }
6948
+ }, m = function(t, e, n) {
6949
+ var r = String(t);
6950
+ return !r || r.length >= e ? t : "" + Array(e + 1 - r.length).join(n) + t;
6951
+ }, v = {
6952
+ s: m,
6953
+ z: function(t) {
6954
+ var e = -t.utcOffset(), n = Math.abs(e), r = Math.floor(n / 60), i = n % 60;
6955
+ return (e <= 0 ? "+" : "-") + m(r, 2, "0") + ":" + m(i, 2, "0");
6956
+ },
6957
+ m: function t(e, n) {
6958
+ if (e.date() < n.date()) return -t(n, e);
6959
+ 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);
6960
+ return +(-(r + (n - i) / (s ? i - u : u - i)) || 0);
6961
+ },
6962
+ a: function(t) {
6963
+ return t < 0 ? Math.ceil(t) || 0 : Math.floor(t);
6964
+ },
6965
+ p: function(t) {
6966
+ return {
6967
+ M: c,
6968
+ y: h,
6969
+ w: o,
6970
+ d: a,
6971
+ D: d,
6972
+ h: u,
6973
+ m: s,
6974
+ s: i,
6975
+ ms: r,
6976
+ Q: f
6977
+ }[t] || String(t || "").toLowerCase().replace(/s$/, "");
6978
+ },
6979
+ u: function(t) {
6980
+ return void 0 === t;
6981
+ }
6982
+ }, g = "en", D = {};
6983
+ D[g] = M;
6984
+ var p = "$isDayjsObject", S = function(t) {
6985
+ return t instanceof _ || !(!t || !t[p]);
6986
+ }, w = function t(e, n, r) {
6987
+ var i;
6988
+ if (!e) return g;
6989
+ if ("string" == typeof e) {
6990
+ var s = e.toLowerCase();
6991
+ D[s] && (i = s), n && (D[s] = n, i = s);
6992
+ var u = e.split("-");
6993
+ if (!i && u.length > 1) return t(u[0]);
6994
+ } else {
6995
+ var a = e.name;
6996
+ D[a] = e, i = a;
6997
+ }
6998
+ return !r && i && (g = i), i || !r && g;
6999
+ }, O = function(t, e) {
7000
+ if (S(t)) return t.clone();
7001
+ var n = "object" == typeof e ? e : {};
7002
+ return n.date = t, n.args = arguments, new _(n);
7003
+ }, b = v;
7004
+ b.l = w, b.i = S, b.w = function(t, e) {
7005
+ return O(t, {
7006
+ locale: e.$L,
7007
+ utc: e.$u,
7008
+ x: e.$x,
7009
+ $offset: e.$offset
7010
+ });
7011
+ };
7012
+ var _ = function() {
7013
+ function M(t) {
7014
+ this.$L = w(t.locale, null, !0), this.parse(t), this.$x = this.$x || t.x || {}, this[p] = !0;
7015
+ }
7016
+ var m = M.prototype;
7017
+ return m.parse = function(t) {
7018
+ this.$d = function(t) {
7019
+ var e = t.date, n = t.utc;
7020
+ if (null === e) return /* @__PURE__ */ new Date(NaN);
7021
+ if (b.u(e)) return /* @__PURE__ */ new Date();
7022
+ if (e instanceof Date) return new Date(e);
7023
+ if ("string" == typeof e && !/Z$/i.test(e)) {
7024
+ var r = e.match($);
7025
+ if (r) {
7026
+ var i = r[2] - 1 || 0, s = (r[7] || "0").substring(0, 3);
7027
+ 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);
7028
+ }
7029
+ }
7030
+ return new Date(e);
7031
+ }(t), this.init();
7032
+ }, m.init = function() {
7033
+ var t = this.$d;
7034
+ 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();
7035
+ }, m.$utils = function() {
7036
+ return b;
7037
+ }, m.isValid = function() {
7038
+ return !(this.$d.toString() === l);
7039
+ }, m.isSame = function(t, e) {
7040
+ var n = O(t);
7041
+ return this.startOf(e) <= n && n <= this.endOf(e);
7042
+ }, m.isAfter = function(t, e) {
7043
+ return O(t) < this.startOf(e);
7044
+ }, m.isBefore = function(t, e) {
7045
+ return this.endOf(e) < O(t);
7046
+ }, m.$g = function(t, e, n) {
7047
+ return b.u(t) ? this[e] : this.set(n, t);
7048
+ }, m.unix = function() {
7049
+ return Math.floor(this.valueOf() / 1e3);
7050
+ }, m.valueOf = function() {
7051
+ return this.$d.getTime();
7052
+ }, m.startOf = function(t, e) {
7053
+ var n = this, r = !!b.u(e) || e, f = b.p(t), l = function(t, e) {
7054
+ var i = b.w(n.$u ? Date.UTC(n.$y, e, t) : new Date(n.$y, e, t), n);
7055
+ return r ? i : i.endOf(a);
7056
+ }, $ = function(t, e) {
7057
+ return b.w(n.toDate()[t].apply(n.toDate("s"), (r ? [
7058
+ 0,
7059
+ 0,
7060
+ 0,
7061
+ 0
7062
+ ] : [
7063
+ 23,
7064
+ 59,
7065
+ 59,
7066
+ 999
7067
+ ]).slice(e)), n);
7068
+ }, y = this.$W, M = this.$M, m = this.$D, v = "set" + (this.$u ? "UTC" : "");
7069
+ switch (f) {
7070
+ case h: return r ? l(1, 0) : l(31, 11);
7071
+ case c: return r ? l(1, M) : l(0, M + 1);
7072
+ case o:
7073
+ var g = this.$locale().weekStart || 0, D = (y < g ? y + 7 : y) - g;
7074
+ return l(r ? m - D : m + (6 - D), M);
7075
+ case a:
7076
+ case d: return $(v + "Hours", 0);
7077
+ case u: return $(v + "Minutes", 1);
7078
+ case s: return $(v + "Seconds", 2);
7079
+ case i: return $(v + "Milliseconds", 3);
7080
+ default: return this.clone();
7081
+ }
7082
+ }, m.endOf = function(t) {
7083
+ return this.startOf(t, !1);
7084
+ }, m.$set = function(t, e) {
7085
+ 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;
7086
+ if (o === c || o === h) {
7087
+ var y = this.clone().set(d, 1);
7088
+ y.$d[l]($), y.init(), this.$d = y.set(d, Math.min(this.$D, y.daysInMonth())).$d;
7089
+ } else l && this.$d[l]($);
7090
+ return this.init(), this;
7091
+ }, m.set = function(t, e) {
7092
+ return this.clone().$set(t, e);
7093
+ }, m.get = function(t) {
7094
+ return this[b.p(t)]();
7095
+ }, m.add = function(r, f) {
7096
+ var d, l = this;
7097
+ r = Number(r);
7098
+ var $ = b.p(f), y = function(t) {
7099
+ var e = O(l);
7100
+ return b.w(e.date(e.date() + Math.round(t * r)), l);
7101
+ };
7102
+ if ($ === c) return this.set(c, this.$M + r);
7103
+ if ($ === h) return this.set(h, this.$y + r);
7104
+ if ($ === a) return y(1);
7105
+ if ($ === o) return y(7);
7106
+ var M = (d = {}, d[s] = e, d[u] = n, d[i] = t, d)[$] || 1, m = this.$d.getTime() + r * M;
7107
+ return b.w(m, this);
7108
+ }, m.subtract = function(t, e) {
7109
+ return this.add(-1 * t, e);
7110
+ }, m.format = function(t) {
7111
+ var e = this, n = this.$locale();
7112
+ if (!this.isValid()) return n.invalidDate || l;
7113
+ 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) {
7114
+ return t && (t[n] || t(e, r)) || i[n].slice(0, s);
7115
+ }, d = function(t) {
7116
+ return b.s(s % 12 || 12, t, "0");
7117
+ }, $ = f || function(t, e, n) {
7118
+ var r = t < 12 ? "AM" : "PM";
7119
+ return n ? r.toLowerCase() : r;
7120
+ };
7121
+ return r.replace(y, (function(t, r) {
7122
+ return r || function(t) {
7123
+ switch (t) {
7124
+ case "YY": return String(e.$y).slice(-2);
7125
+ case "YYYY": return b.s(e.$y, 4, "0");
7126
+ case "M": return a + 1;
7127
+ case "MM": return b.s(a + 1, 2, "0");
7128
+ case "MMM": return h(n.monthsShort, a, c, 3);
7129
+ case "MMMM": return h(c, a);
7130
+ case "D": return e.$D;
7131
+ case "DD": return b.s(e.$D, 2, "0");
7132
+ case "d": return String(e.$W);
7133
+ case "dd": return h(n.weekdaysMin, e.$W, o, 2);
7134
+ case "ddd": return h(n.weekdaysShort, e.$W, o, 3);
7135
+ case "dddd": return o[e.$W];
7136
+ case "H": return String(s);
7137
+ case "HH": return b.s(s, 2, "0");
7138
+ case "h": return d(1);
7139
+ case "hh": return d(2);
7140
+ case "a": return $(s, u, !0);
7141
+ case "A": return $(s, u, !1);
7142
+ case "m": return String(u);
7143
+ case "mm": return b.s(u, 2, "0");
7144
+ case "s": return String(e.$s);
7145
+ case "ss": return b.s(e.$s, 2, "0");
7146
+ case "SSS": return b.s(e.$ms, 3, "0");
7147
+ case "Z": return i;
7148
+ }
7149
+ return null;
7150
+ }(t) || i.replace(":", "");
7151
+ }));
7152
+ }, m.utcOffset = function() {
7153
+ return 15 * -Math.round(this.$d.getTimezoneOffset() / 15);
7154
+ }, m.diff = function(r, d, l) {
7155
+ var $, y = this, M = b.p(d), m = O(r), v = (m.utcOffset() - this.utcOffset()) * e, g = this - m, D = function() {
7156
+ return b.m(y, m);
7157
+ };
7158
+ switch (M) {
7159
+ case h:
7160
+ $ = D() / 12;
7161
+ break;
7162
+ case c:
7163
+ $ = D();
7164
+ break;
7165
+ case f:
7166
+ $ = D() / 3;
7167
+ break;
7168
+ case o:
7169
+ $ = (g - v) / 6048e5;
7170
+ break;
7171
+ case a:
7172
+ $ = (g - v) / 864e5;
7173
+ break;
7174
+ case u:
7175
+ $ = g / n;
7176
+ break;
7177
+ case s:
7178
+ $ = g / e;
7179
+ break;
7180
+ case i:
7181
+ $ = g / t;
7182
+ break;
7183
+ default: $ = g;
7184
+ }
7185
+ return l ? $ : b.a($);
7186
+ }, m.daysInMonth = function() {
7187
+ return this.endOf(c).$D;
7188
+ }, m.$locale = function() {
7189
+ return D[this.$L];
7190
+ }, m.locale = function(t, e) {
7191
+ if (!t) return this.$L;
7192
+ var n = this.clone(), r = w(t, e, !0);
7193
+ return r && (n.$L = r), n;
7194
+ }, m.clone = function() {
7195
+ return b.w(this.$d, this);
7196
+ }, m.toDate = function() {
7197
+ return new Date(this.valueOf());
7198
+ }, m.toJSON = function() {
7199
+ return this.isValid() ? this.toISOString() : null;
7200
+ }, m.toISOString = function() {
7201
+ return this.$d.toISOString();
7202
+ }, m.toString = function() {
7203
+ return this.$d.toUTCString();
7204
+ }, M;
7205
+ }(), k = _.prototype;
7206
+ return O.prototype = k, [
7207
+ ["$ms", r],
7208
+ ["$s", i],
7209
+ ["$m", s],
7210
+ ["$H", u],
7211
+ ["$W", a],
7212
+ ["$M", c],
7213
+ ["$y", h],
7214
+ ["$D", d]
7215
+ ].forEach((function(t) {
7216
+ k[t[1]] = function(e) {
7217
+ return this.$g(e, t[0], t[1]);
7218
+ };
7219
+ })), O.extend = function(t, e) {
7220
+ return t.$i || (t(e, _, O), t.$i = !0), O;
7221
+ }, O.locale = w, O.isDayjs = S, O.unix = function(t) {
7222
+ return O(1e3 * t);
7223
+ }, O.en = D[g], O.Ls = D, O.p = {}, O;
7224
+ }));
7225
+ })))(), 1);
7226
+ var isProduction = process.env.NODE_ENV === "production";
7227
+ var prefix = "Invariant failed";
7228
+ function invariant(condition, message) {
7229
+ if (condition) return;
7230
+ if (isProduction) throw new Error(prefix);
7231
+ var provided = typeof message === "function" ? message() : message;
7232
+ var value = provided ? "".concat(prefix, ": ").concat(provided) : prefix;
7233
+ throw new Error(value);
7234
+ }
7235
+ //#endregion
7236
+ //#region src/api/routes/auth.ts
7237
+ const scryptAsync = promisify(scrypt);
7238
+ const sessionCookieName = "jukebox_session";
7239
+ const rateLimitWindowMs = 900 * 1e3;
7240
+ const rateLimitBuckets = /* @__PURE__ */ new Map();
7241
+ function clientIp(headers) {
7242
+ const forwarded = headers.get("x-forwarded-for");
7243
+ if (forwarded) {
7244
+ const first = forwarded.split(",")[0]?.trim();
7245
+ if (first) return first;
7246
+ }
7247
+ return headers.get("x-real-ip") ?? "unknown";
7248
+ }
7249
+ function checkRateLimit(ip) {
7250
+ const now = Date.now();
7251
+ const cutoff = now - rateLimitWindowMs;
7252
+ const entry = rateLimitBuckets.get(ip) ?? { attempts: [] };
7253
+ entry.attempts = entry.attempts.filter((time) => time > cutoff);
7254
+ if (entry.attempts.length >= 5) {
7255
+ const retryAfterMs = (entry.attempts[0] ?? now) + rateLimitWindowMs - now;
7256
+ const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1e3));
7257
+ rateLimitBuckets.set(ip, entry);
7258
+ return {
7259
+ allowed: false,
7260
+ retryAfterSeconds
7261
+ };
7262
+ }
7263
+ rateLimitBuckets.set(ip, entry);
7264
+ return {
7265
+ allowed: true,
7266
+ retryAfterSeconds: 0
7267
+ };
7268
+ }
7269
+ function recordFailedAttempt(ip) {
7270
+ const entry = rateLimitBuckets.get(ip) ?? { attempts: [] };
7271
+ entry.attempts.push(Date.now());
7272
+ rateLimitBuckets.set(ip, entry);
7273
+ }
7274
+ function clearAttempts(ip) {
7275
+ rateLimitBuckets.delete(ip);
7276
+ }
7277
+ async function loadAuthConfig() {
7278
+ const [existing] = await db.select().from(authConfig).where(eq(authConfig.id, 1)).limit(1);
7279
+ if (existing) return existing;
7280
+ const [created] = await db.insert(authConfig).values({
7281
+ id: 1,
7282
+ passwordHash: null,
7283
+ updatedAt: Date.now()
7284
+ }).returning();
7285
+ invariant(created, "Failed to initialize auth config.");
7286
+ return created;
7287
+ }
7288
+ const scryptKeyLength = 64;
7289
+ async function hashPassword(password) {
7290
+ const salt = randomBytes(16);
7291
+ const derived = await scryptAsync(password, salt, scryptKeyLength);
7292
+ return `scrypt$${salt.toString("base64")}$${derived.toString("base64")}`;
7293
+ }
7294
+ async function verifyPassword(password, stored) {
7295
+ const parts = stored.split("$");
7296
+ if (parts.length !== 3 || parts[0] !== "scrypt") return false;
7297
+ try {
7298
+ const salt = Buffer.from(parts[1] ?? "", "base64");
7299
+ const expected = Buffer.from(parts[2] ?? "", "base64");
7300
+ const derived = await scryptAsync(password, salt, expected.length);
7301
+ if (derived.length !== expected.length) return false;
7302
+ return timingSafeEqual(derived, expected);
7303
+ } catch {
7304
+ return false;
7305
+ }
7306
+ }
7307
+ function createSessionToken() {
7308
+ return randomBytes(32).toString("base64url");
7309
+ }
7310
+ async function createSession(userAgent) {
7311
+ const now = (0, import_dayjs_min.default)();
7312
+ const token = createSessionToken();
7313
+ await db.insert(sessions$1).values({
7314
+ id: token,
7315
+ createdAt: now.valueOf(),
7316
+ expiresAt: now.add(30, "day").valueOf(),
7317
+ lastSeenAt: now.valueOf(),
7318
+ userAgent
7319
+ });
7320
+ return token;
7321
+ }
7322
+ async function deleteAllSessions() {
7323
+ await db.delete(sessions$1);
7324
+ }
7325
+ async function deleteSession(id) {
7326
+ await db.delete(sessions$1).where(eq(sessions$1.id, id));
7327
+ }
7328
+ function sessionCookieOptions() {
7329
+ return {
7330
+ httpOnly: true,
7331
+ maxAge: 720 * 60 * 60,
7332
+ path: "/",
7333
+ sameSite: "Lax",
7334
+ secure: process.env.NODE_ENV === "production"
7335
+ };
7336
+ }
7337
+ const authRoutes = new Hono();
7338
+ authRoutes.get("/status", async (context) => {
7339
+ if (!((await loadAuthConfig()).passwordHash !== null)) return context.json({
7340
+ enabled: false,
7341
+ authenticated: true
7342
+ });
7343
+ const cookie = getCookie(context, sessionCookieName);
7344
+ if (!cookie) return context.json({
7345
+ enabled: true,
7346
+ authenticated: false
7347
+ });
7348
+ const [session] = await db.select().from(sessions$1).where(eq(sessions$1.id, cookie)).limit(1);
7349
+ const authenticated = session !== void 0 && session.expiresAt > Date.now();
7350
+ return context.json({
7351
+ enabled: true,
7352
+ authenticated
7353
+ });
7354
+ });
7355
+ authRoutes.post("/login", async (context) => {
7356
+ const ip = clientIp(context.req.raw.headers);
7357
+ const limit = checkRateLimit(ip);
7358
+ if (!limit.allowed) {
7359
+ const minutes = Math.ceil(limit.retryAfterSeconds / 60);
7360
+ context.header("Retry-After", String(limit.retryAfterSeconds));
7361
+ return context.json({ error: { message: `Too many attempts. Try again in ${minutes} ${minutes === 1 ? "minute" : "minutes"}.` } }, 429);
7362
+ }
7363
+ let body;
7364
+ try {
7365
+ body = await context.req.json();
7366
+ } catch {
7367
+ return context.json({ error: { message: "Invalid request body." } }, 400);
7368
+ }
7369
+ const password = typeof body.password === "string" ? body.password : "";
7370
+ if (password.length === 0) return context.json({ error: { message: "Incorrect password." } }, 401);
7371
+ const config = await loadAuthConfig();
7372
+ if (config.passwordHash === null) return context.json({ error: { message: "Password login is disabled on this server." } }, 400);
7373
+ if (!await verifyPassword(password, config.passwordHash)) {
7374
+ recordFailedAttempt(ip);
7375
+ return context.json({ error: { message: "Incorrect password." } }, 401);
7376
+ }
7377
+ clearAttempts(ip);
7378
+ setCookie(context, sessionCookieName, await createSession(context.req.header("user-agent") ?? null), sessionCookieOptions());
7379
+ return context.body(null, 204);
7380
+ });
7381
+ authRoutes.post("/logout", async (context) => {
7382
+ const token = getCookie(context, sessionCookieName);
7383
+ if (token) await deleteSession(token);
7384
+ deleteCookie(context, sessionCookieName, { path: "/" });
7385
+ return context.body(null, 204);
7386
+ });
7387
+ authRoutes.post("/password", async (context) => {
7388
+ let body;
7389
+ try {
7390
+ body = await context.req.json();
7391
+ } catch {
7392
+ return context.json({ error: { message: "Invalid request body." } }, 400);
7393
+ }
7394
+ const newPassword = typeof body.newPassword === "string" ? body.newPassword : "";
7395
+ const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : "";
7396
+ const config = await loadAuthConfig();
7397
+ if (config.passwordHash !== null) {
7398
+ if (!await verifyPassword(currentPassword, config.passwordHash ?? "")) return context.json({ error: { message: "Current password is incorrect." } }, 401);
7399
+ }
7400
+ if (newPassword.length === 0) {
7401
+ await db.update(authConfig).set({
7402
+ passwordHash: null,
7403
+ updatedAt: Date.now()
7404
+ }).where(eq(authConfig.id, 1));
7405
+ await deleteAllSessions();
7406
+ deleteCookie(context, sessionCookieName, { path: "/" });
7407
+ return context.json({ enabled: false });
7408
+ }
7409
+ if (newPassword.length < 8) return context.json({ error: { message: `Choose a password of at least 8 characters.` } }, 400);
7410
+ const hash = await hashPassword(newPassword);
7411
+ await db.update(authConfig).set({
7412
+ passwordHash: hash,
7413
+ updatedAt: Date.now()
7414
+ }).where(eq(authConfig.id, 1));
7415
+ await deleteAllSessions();
7416
+ setCookie(context, sessionCookieName, await createSession(context.req.header("user-agent") ?? null), sessionCookieOptions());
7417
+ return context.json({ enabled: true });
7418
+ });
7419
+ //#endregion
7420
+ //#region src/api/middleware/auth.ts
7421
+ const lastSeenThrottleMs = 60 * 1e3;
7422
+ let lastSweepAt = 0;
7423
+ const sweepIntervalMs = 300 * 1e3;
7424
+ async function sweepExpiredSessions(now) {
7425
+ if (now - lastSweepAt < sweepIntervalMs) return;
7426
+ lastSweepAt = now;
7427
+ await db.delete(sessions$1).where(lt(sessions$1.expiresAt, now));
7428
+ }
7429
+ const authMiddleware = createMiddleware(async (context, next) => {
7430
+ const pathname = new URL(context.req.url).pathname;
7431
+ if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/setup")) return next();
7432
+ const [config] = await db.select().from(authConfig).where(eq(authConfig.id, 1)).limit(1);
7433
+ if (!config || config.passwordHash === null) return next();
7434
+ const token = getCookie(context, sessionCookieName);
7435
+ if (!token) return context.json({ error: { message: "Authentication required." } }, 401);
7436
+ const [session] = await db.select().from(sessions$1).where(eq(sessions$1.id, token)).limit(1);
7437
+ const now = Date.now();
7438
+ await sweepExpiredSessions(now);
7439
+ if (!session || session.expiresAt <= now) {
7440
+ if (session) await db.delete(sessions$1).where(eq(sessions$1.id, session.id));
7441
+ return context.json({ error: { message: "Your session has expired. Please sign in again." } }, 401);
7442
+ }
7443
+ if (now - session.lastSeenAt > lastSeenThrottleMs) await db.update(sessions$1).set({ lastSeenAt: now }).where(eq(sessions$1.id, session.id));
7444
+ return next();
7445
+ });
7446
+ //#endregion
7447
+ //#region src/api/middleware/profile.ts
7448
+ const profileCookieName = "jukebox_profile_id";
7449
+ const oneYearSeconds = 3600 * 24 * 365;
7450
+ function setProfileCookie(context, profileId) {
7451
+ setCookie(context, profileCookieName, String(profileId), {
7452
+ httpOnly: true,
7453
+ maxAge: oneYearSeconds,
7454
+ path: "/",
7455
+ sameSite: "Lax"
7456
+ });
7457
+ }
7458
+ async function readProfileFromCookie(rawCookieValue) {
7459
+ if (!rawCookieValue) return null;
7460
+ const parsedId = parseInt(rawCookieValue, 10);
7461
+ if (isNaN(parsedId)) return null;
7462
+ const [profile] = await db.select().from(profiles).where(eq(profiles.id, parsedId)).limit(1);
7463
+ return profile ?? null;
7464
+ }
7465
+ async function fallbackProfile() {
7466
+ const [latest] = await db.select().from(profiles).orderBy(desc(profiles.createdAt)).limit(1);
7467
+ if (latest) return latest;
7468
+ const now = /* @__PURE__ */ new Date();
7469
+ const [created] = await db.insert(profiles).values({
7470
+ name: "Default",
7471
+ emoji: "🍿",
7472
+ createdAt: now
7473
+ }).returning();
7474
+ if (!created) throw new Error("Failed to create default profile");
7475
+ return created;
7476
+ }
7477
+ const profileMiddleware = createMiddleware(async (context, next) => {
7478
+ const fromCookie = await readProfileFromCookie(getCookie(context, profileCookieName));
7479
+ const profile = fromCookie ?? await fallbackProfile();
7480
+ if (!fromCookie || fromCookie.id !== profile.id) setProfileCookie(context, profile.id);
7481
+ context.set("profileId", profile.id);
7482
+ await next();
7483
+ });
6725
7484
  //#endregion
6726
7485
  //#region src/api/routes/episode-progress.ts
6727
7486
  const episodeProgressRoutes = new Hono();
6728
7487
  episodeProgressRoutes.get("/show/:showId", async (context) => {
7488
+ const profileId = context.get("profileId");
6729
7489
  const showId = parseInt(context.req.param("showId"), 10);
6730
7490
  if (isNaN(showId)) return context.json({ error: { message: "Invalid show ID" } }, 400);
6731
7491
  const episodeIds = (await db.select({ id: episodes.id }).from(episodes).where(eq(episodes.showId, showId))).map((episode) => episode.id);
@@ -6735,7 +7495,7 @@ episodeProgressRoutes.get("/show/:showId", async (context) => {
6735
7495
  currentTime: watchProgress.currentTime,
6736
7496
  duration: watchProgress.duration,
6737
7497
  updatedAt: watchProgress.updatedAt
6738
- }).from(watchProgress).where(and(inArray(watchProgress.episodeId, episodeIds), gt(watchProgress.currentTime, 0)));
7498
+ }).from(watchProgress).where(and(eq(watchProgress.profileId, profileId), inArray(watchProgress.episodeId, episodeIds), gt(watchProgress.currentTime, 0)));
6739
7499
  const progressMap = {};
6740
7500
  for (const row of progressRows) if (row.episodeId !== null) progressMap[row.episodeId] = {
6741
7501
  currentTime: row.currentTime,
@@ -6745,9 +7505,10 @@ episodeProgressRoutes.get("/show/:showId", async (context) => {
6745
7505
  return context.json(progressMap);
6746
7506
  });
6747
7507
  episodeProgressRoutes.get("/:episodeId", async (context) => {
7508
+ const profileId = context.get("profileId");
6748
7509
  const episodeId = parseInt(context.req.param("episodeId"), 10);
6749
7510
  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);
7511
+ const [progress] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.episodeId, episodeId))).limit(1);
6751
7512
  if (!progress) return context.json({
6752
7513
  currentTime: 0,
6753
7514
  duration: null
@@ -6758,18 +7519,20 @@ episodeProgressRoutes.get("/:episodeId", async (context) => {
6758
7519
  });
6759
7520
  });
6760
7521
  episodeProgressRoutes.put("/:episodeId", async (context) => {
7522
+ const profileId = context.get("profileId");
6761
7523
  const episodeId = parseInt(context.req.param("episodeId"), 10);
6762
7524
  if (isNaN(episodeId)) return context.json({ error: { message: "Invalid episode ID" } }, 400);
6763
7525
  const body = await context.req.json();
6764
7526
  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);
7527
+ const [existing] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.episodeId, episodeId))).limit(1);
6766
7528
  const now = /* @__PURE__ */ new Date();
6767
7529
  if (existing) await db.update(watchProgress).set({
6768
7530
  currentTime: Math.floor(body.currentTime),
6769
7531
  duration: body.duration ? Math.floor(body.duration) : existing.duration,
6770
7532
  updatedAt: now
6771
- }).where(eq(watchProgress.episodeId, episodeId));
7533
+ }).where(eq(watchProgress.id, existing.id));
6772
7534
  else await db.insert(watchProgress).values({
7535
+ profileId,
6773
7536
  episodeId,
6774
7537
  currentTime: Math.floor(body.currentTime),
6775
7538
  duration: body.duration ? Math.floor(body.duration) : null,
@@ -6778,17 +7541,6 @@ episodeProgressRoutes.put("/:episodeId", async (context) => {
6778
7541
  return context.json({ success: true });
6779
7542
  });
6780
7543
  //#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
7544
  //#region src/api/routes/episode-stream.ts
6793
7545
  const mimeTypes$1 = {
6794
7546
  ".avi": "video/x-msvideo",
@@ -6856,8 +7608,8 @@ helloRoutes.get("/:name", (c) => c.json({ message: `Hello, ${c.req.param("name")
6856
7608
  //#region src/api/routes/library.ts
6857
7609
  const libraryRoutes = new Hono();
6858
7610
  libraryRoutes.get("/movies", async (c) => {
6859
- const movies$2 = await db.select().from(movies).orderBy(movies.title);
6860
- return c.json(movies$2);
7611
+ const movies$3 = await db.select().from(movies).orderBy(movies.title);
7612
+ return c.json(movies$3);
6861
7613
  });
6862
7614
  libraryRoutes.get("/movies/:id", async (c) => {
6863
7615
  const id = parseInt(c.req.param("id"), 10);
@@ -6870,7 +7622,8 @@ libraryRoutes.get("/movies/:id", async (c) => {
6870
7622
  //#region src/api/routes/progress.ts
6871
7623
  const progressRoutes = new Hono();
6872
7624
  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)`);
7625
+ const profileId = context.get("profileId");
7626
+ const inProgressFilter = and(eq(watchProgress.profileId, profileId), gt(watchProgress.currentTime, 0), sql`(${watchProgress.duration} IS NULL OR ${watchProgress.currentTime} < ${watchProgress.duration} * 0.9)`);
6874
7627
  const movieResults = await db.select({
6875
7628
  currentTime: watchProgress.currentTime,
6876
7629
  duration: watchProgress.duration,
@@ -6884,7 +7637,7 @@ progressRoutes.get("/continue-watching", async (context) => {
6884
7637
  show: shows,
6885
7638
  updatedAt: watchProgress.updatedAt
6886
7639
  }).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) => ({
7640
+ const movies$2 = movieResults.map((result) => ({
6888
7641
  ...result,
6889
7642
  type: "movie"
6890
7643
  }));
@@ -6892,13 +7645,14 @@ progressRoutes.get("/continue-watching", async (context) => {
6892
7645
  ...result,
6893
7646
  type: "episode"
6894
7647
  }));
6895
- const combined = [...movies$1, ...episodes$2].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).slice(0, 20);
7648
+ const combined = [...movies$2, ...episodes$2].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).slice(0, 20);
6896
7649
  return context.json(combined);
6897
7650
  });
6898
7651
  progressRoutes.get("/:movieId", async (c) => {
7652
+ const profileId = c.get("profileId");
6899
7653
  const movieId = parseInt(c.req.param("movieId"), 10);
6900
7654
  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);
7655
+ const [progress] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.movieId, movieId))).limit(1);
6902
7656
  if (!progress) return c.json({
6903
7657
  currentTime: 0,
6904
7658
  duration: null
@@ -6909,18 +7663,20 @@ progressRoutes.get("/:movieId", async (c) => {
6909
7663
  });
6910
7664
  });
6911
7665
  progressRoutes.put("/:movieId", async (c) => {
7666
+ const profileId = c.get("profileId");
6912
7667
  const movieId = parseInt(c.req.param("movieId"), 10);
6913
7668
  if (isNaN(movieId)) return c.json({ error: { message: "Invalid movie ID" } }, 400);
6914
7669
  const body = await c.req.json();
6915
7670
  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);
7671
+ const [existing] = await db.select().from(watchProgress).where(and(eq(watchProgress.profileId, profileId), eq(watchProgress.movieId, movieId))).limit(1);
6917
7672
  const now = /* @__PURE__ */ new Date();
6918
7673
  if (existing) await db.update(watchProgress).set({
6919
7674
  currentTime: Math.floor(body.currentTime),
6920
7675
  duration: body.duration ? Math.floor(body.duration) : existing.duration,
6921
7676
  updatedAt: now
6922
- }).where(eq(watchProgress.movieId, movieId));
7677
+ }).where(eq(watchProgress.id, existing.id));
6923
7678
  else await db.insert(watchProgress).values({
7679
+ profileId,
6924
7680
  movieId,
6925
7681
  currentTime: Math.floor(body.currentTime),
6926
7682
  duration: body.duration ? Math.floor(body.duration) : null,
@@ -7850,6 +8606,157 @@ setupRoutes.post("/complete", async (context) => {
7850
8606
  return context.json({ success: true });
7851
8607
  });
7852
8608
  //#endregion
8609
+ //#region src/api/routes/profiles.ts
8610
+ const profileRoutes = new Hono();
8611
+ profileRoutes.get("/", async (context) => {
8612
+ const profiles$1 = await db.select().from(profiles).orderBy(desc(profiles.createdAt));
8613
+ return context.json(profiles$1);
8614
+ });
8615
+ profileRoutes.get("/active", async (context) => {
8616
+ const profileId = context.get("profileId");
8617
+ const [profile] = await db.select().from(profiles).where(eq(profiles.id, profileId)).limit(1);
8618
+ if (!profile) return context.json({ error: { message: "Active profile not found" } }, 404);
8619
+ return context.json(profile);
8620
+ });
8621
+ profileRoutes.post("/", async (context) => {
8622
+ const body = await context.req.json();
8623
+ const name = body.name?.trim();
8624
+ const emoji = body.emoji?.trim();
8625
+ if (!name || !emoji) return context.json({ error: { message: "name and emoji are required" } }, 400);
8626
+ const now = /* @__PURE__ */ new Date();
8627
+ try {
8628
+ const [created] = await db.insert(profiles).values({
8629
+ name,
8630
+ emoji,
8631
+ createdAt: now
8632
+ }).returning();
8633
+ return context.json(created, 201);
8634
+ } catch (error) {
8635
+ const message = error instanceof Error && error.message.includes("UNIQUE") ? "A profile with that name already exists" : "Failed to create profile";
8636
+ return context.json({ error: { message } }, 400);
8637
+ }
8638
+ });
8639
+ profileRoutes.patch("/:id", async (context) => {
8640
+ const id = parseInt(context.req.param("id"), 10);
8641
+ if (isNaN(id)) return context.json({ error: { message: "Invalid profile id" } }, 400);
8642
+ const body = await context.req.json();
8643
+ const updates = {};
8644
+ if (typeof body.name === "string" && body.name.trim()) updates.name = body.name.trim();
8645
+ if (typeof body.emoji === "string" && body.emoji.trim()) updates.emoji = body.emoji.trim();
8646
+ if (Object.keys(updates).length === 0) return context.json({ error: { message: "Nothing to update" } }, 400);
8647
+ try {
8648
+ const [updated] = await db.update(profiles).set(updates).where(eq(profiles.id, id)).returning();
8649
+ if (!updated) return context.json({ error: { message: "Profile not found" } }, 404);
8650
+ return context.json(updated);
8651
+ } catch (error) {
8652
+ const message = error instanceof Error && error.message.includes("UNIQUE") ? "A profile with that name already exists" : "Failed to update profile";
8653
+ return context.json({ error: { message } }, 400);
8654
+ }
8655
+ });
8656
+ profileRoutes.delete("/:id", async (context) => {
8657
+ const id = parseInt(context.req.param("id"), 10);
8658
+ if (isNaN(id)) return context.json({ error: { message: "Invalid profile id" } }, 400);
8659
+ const all = await db.select({ id: profiles.id }).from(profiles);
8660
+ if (all.length <= 1) return context.json({ error: { message: "Cannot delete the last remaining profile" } }, 400);
8661
+ await db.delete(profiles).where(eq(profiles.id, id));
8662
+ if (context.get("profileId") === id) {
8663
+ const next = all.find((profile) => profile.id !== id);
8664
+ if (next) setProfileCookie(context, next.id);
8665
+ }
8666
+ return context.json({ success: true });
8667
+ });
8668
+ profileRoutes.post("/:id/activate", async (context) => {
8669
+ const id = parseInt(context.req.param("id"), 10);
8670
+ if (isNaN(id)) return context.json({ error: { message: "Invalid profile id" } }, 400);
8671
+ const [profile] = await db.select().from(profiles).where(eq(profiles.id, id)).limit(1);
8672
+ if (!profile) return context.json({ error: { message: "Profile not found" } }, 404);
8673
+ setProfileCookie(context, profile.id);
8674
+ return context.json(profile);
8675
+ });
8676
+ //#endregion
8677
+ //#region src/api/routes/favorites.ts
8678
+ const favoriteRoutes = new Hono();
8679
+ favoriteRoutes.get("/", async (context) => {
8680
+ const profileId = context.get("profileId");
8681
+ const movieFavorites = await db.select({
8682
+ createdAt: favorites.createdAt,
8683
+ movie: movies
8684
+ }).from(favorites).innerJoin(movies, eq(favorites.movieId, movies.id)).where(eq(favorites.profileId, profileId)).orderBy(desc(favorites.createdAt));
8685
+ const showFavorites = await db.select({
8686
+ createdAt: favorites.createdAt,
8687
+ show: shows
8688
+ }).from(favorites).innerJoin(shows, eq(favorites.showId, shows.id)).where(eq(favorites.profileId, profileId)).orderBy(desc(favorites.createdAt));
8689
+ const movies$1 = movieFavorites.map((row) => ({
8690
+ ...row,
8691
+ type: "movie"
8692
+ }));
8693
+ const shows$2 = showFavorites.map((row) => ({
8694
+ ...row,
8695
+ type: "show"
8696
+ }));
8697
+ const combined = [...movies$1, ...shows$2].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
8698
+ return context.json(combined);
8699
+ });
8700
+ favoriteRoutes.get("/status", async (context) => {
8701
+ const profileId = context.get("profileId");
8702
+ const movieIdParam = context.req.query("movieId");
8703
+ const showIdParam = context.req.query("showId");
8704
+ if (movieIdParam) {
8705
+ const movieId = parseInt(movieIdParam, 10);
8706
+ if (isNaN(movieId)) return context.json({ error: { message: "Invalid movie ID" } }, 400);
8707
+ const [favorite] = await db.select({ id: favorites.id }).from(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.movieId, movieId))).limit(1);
8708
+ return context.json({ favorite: !!favorite });
8709
+ }
8710
+ if (showIdParam) {
8711
+ const showId = parseInt(showIdParam, 10);
8712
+ if (isNaN(showId)) return context.json({ error: { message: "Invalid show ID" } }, 400);
8713
+ const [favorite] = await db.select({ id: favorites.id }).from(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.showId, showId))).limit(1);
8714
+ return context.json({ favorite: !!favorite });
8715
+ }
8716
+ return context.json({ error: { message: "movieId or showId required" } }, 400);
8717
+ });
8718
+ favoriteRoutes.post("/", async (context) => {
8719
+ const profileId = context.get("profileId");
8720
+ const body = await context.req.json();
8721
+ 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);
8722
+ const now = /* @__PURE__ */ new Date();
8723
+ try {
8724
+ await db.insert(favorites).values({
8725
+ profileId,
8726
+ movieId: body.movieId ?? null,
8727
+ showId: body.showId ?? null,
8728
+ createdAt: now
8729
+ });
8730
+ } catch (error) {
8731
+ if (!(error instanceof Error) || !error.message.includes("UNIQUE")) throw error;
8732
+ }
8733
+ return context.json({ success: true });
8734
+ });
8735
+ favoriteRoutes.delete("/", async (context) => {
8736
+ const profileId = context.get("profileId");
8737
+ const body = await context.req.json();
8738
+ if (typeof body.movieId === "number") {
8739
+ await db.delete(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.movieId, body.movieId)));
8740
+ return context.json({ success: true });
8741
+ }
8742
+ if (typeof body.showId === "number") {
8743
+ await db.delete(favorites).where(and(eq(favorites.profileId, profileId), eq(favorites.showId, body.showId)));
8744
+ return context.json({ success: true });
8745
+ }
8746
+ return context.json({ error: { message: "movieId or showId required" } }, 400);
8747
+ });
8748
+ //#endregion
8749
+ //#region src/lib/watched.ts
8750
+ /**
8751
+ * Shared "watched" threshold used across the server and client.
8752
+ *
8753
+ * An episode (or movie) is considered complete once the profile has
8754
+ * progressed at least this fraction of the total duration. Kept in a single
8755
+ * module so server endpoints ("Up Next", next-episode) and client overlays
8756
+ * ("Up Next" countdown) can't drift.
8757
+ */
8758
+ const watchedThreshold = .9;
8759
+ //#endregion
7853
8760
  //#region src/api/routes/shows.ts
7854
8761
  const showRoutes = new Hono();
7855
8762
  showRoutes.get("/", async (context) => {
@@ -7876,6 +8783,40 @@ showRoutes.get("/episodes/:id", async (context) => {
7876
8783
  show
7877
8784
  });
7878
8785
  });
8786
+ showRoutes.get("/:showId/next-episode", async (context) => {
8787
+ const profileId = context.get("profileId");
8788
+ const showId = parseInt(context.req.param("showId"), 10);
8789
+ const afterEpisodeIdParam = context.req.query("afterEpisodeId");
8790
+ const afterEpisodeId = afterEpisodeIdParam ? parseInt(afterEpisodeIdParam, 10) : NaN;
8791
+ if (isNaN(showId) || isNaN(afterEpisodeId)) return context.json({ error: { message: "Provide a numeric showId and a numeric afterEpisodeId query parameter." } }, 400);
8792
+ const [currentEpisode] = await db.select().from(episodes).where(and(eq(episodes.id, afterEpisodeId), eq(episodes.showId, showId))).limit(1);
8793
+ if (!currentEpisode) return context.json({ error: { message: "That episode does not belong to this show. Double-check the showId and afterEpisodeId." } }, 404);
8794
+ const laterEpisodes = (await db.select().from(episodes).where(eq(episodes.showId, showId)).orderBy(episodes.seasonNumber, episodes.episodeNumber)).filter((candidate) => {
8795
+ if (candidate.seasonNumber > currentEpisode.seasonNumber) return true;
8796
+ if (candidate.seasonNumber < currentEpisode.seasonNumber) return false;
8797
+ return candidate.episodeNumber > currentEpisode.episodeNumber;
8798
+ });
8799
+ if (laterEpisodes.length === 0) return context.json({ error: { message: "No more episodes after this one." } }, 404);
8800
+ const laterIds = laterEpisodes.map((candidate) => candidate.id);
8801
+ const progressRows = await db.select().from(watchProgress).where(eq(watchProgress.profileId, profileId));
8802
+ const progressByEpisodeId = /* @__PURE__ */ new Map();
8803
+ for (const row of progressRows) if (row.episodeId !== null && laterIds.includes(row.episodeId)) progressByEpisodeId.set(row.episodeId, {
8804
+ currentTime: row.currentTime,
8805
+ duration: row.duration
8806
+ });
8807
+ const nextEpisode = laterEpisodes.find((candidate) => {
8808
+ const progress = progressByEpisodeId.get(candidate.id);
8809
+ if (!progress) return true;
8810
+ if (!progress.duration || progress.duration <= 0) return true;
8811
+ return progress.currentTime / progress.duration < watchedThreshold;
8812
+ });
8813
+ if (!nextEpisode) return context.json({ error: { message: "No more episodes after this one." } }, 404);
8814
+ const [show] = await db.select().from(shows).where(eq(shows.id, showId)).limit(1);
8815
+ return context.json({
8816
+ episode: nextEpisode,
8817
+ show
8818
+ });
8819
+ });
7879
8820
  showRoutes.get("/:id", async (context) => {
7880
8821
  const id = parseInt(context.req.param("id"), 10);
7881
8822
  if (isNaN(id)) return context.json({ error: { message: "Invalid show ID" } }, 400);
@@ -7952,26 +8893,276 @@ streamRoutes.get("/:id", async (context) => {
7952
8893
  } });
7953
8894
  });
7954
8895
  //#endregion
8896
+ //#region src/api/routes/transcode.ts
8897
+ const logger = {
8898
+ error: (...args) => console.error("[transcode]", ...args),
8899
+ info: (...args) => console.info("[transcode]", ...args),
8900
+ warn: (...args) => console.warn("[transcode]", ...args)
8901
+ };
8902
+ const reapIdleMs = 1800 * 1e3;
8903
+ const reaperIntervalMs = 300 * 1e3;
8904
+ const sessions = /* @__PURE__ */ new Map();
8905
+ function sessionKey(fileId, profileId) {
8906
+ return `${fileId}:${profileId}`;
8907
+ }
8908
+ function safeDirectoryName(fileId, profileId) {
8909
+ return `${fileId}__${profileId}`;
8910
+ }
8911
+ function getTranscodeRoot() {
8912
+ return path.join(os.tmpdir(), "jukebox-transcode");
8913
+ }
8914
+ function waitForPlaylist(playlistPath, timeoutMs) {
8915
+ return new Promise((resolve, reject) => {
8916
+ const startedAt = Date.now();
8917
+ const check = () => {
8918
+ if (existsSync(playlistPath)) {
8919
+ resolve();
8920
+ return;
8921
+ }
8922
+ if (Date.now() - startedAt > timeoutMs) {
8923
+ reject(/* @__PURE__ */ new Error("Couldn't prepare this file for casting. Check that ffmpeg is installed."));
8924
+ return;
8925
+ }
8926
+ setTimeout(check, 200);
8927
+ };
8928
+ check();
8929
+ });
8930
+ }
8931
+ function startTranscode({ fileId, filePath, profileId, spawnImplementation }) {
8932
+ const key = sessionKey(fileId, profileId);
8933
+ const existing = sessions.get(key);
8934
+ if (existing) {
8935
+ existing.lastAccessedAt = Date.now();
8936
+ return existing;
8937
+ }
8938
+ const tempDir = path.join(getTranscodeRoot(), safeDirectoryName(fileId, profileId));
8939
+ mkdirSync(tempDir, { recursive: true });
8940
+ const playlistPath = path.join(tempDir, "index.m3u8");
8941
+ const args = [
8942
+ "-i",
8943
+ filePath,
8944
+ "-c:v",
8945
+ "copy",
8946
+ "-c:a",
8947
+ "aac",
8948
+ "-ac",
8949
+ "2",
8950
+ "-f",
8951
+ "hls",
8952
+ "-hls_time",
8953
+ "6",
8954
+ "-hls_list_size",
8955
+ "0",
8956
+ "-hls_segment_filename",
8957
+ path.join(tempDir, "segment-%03d.ts"),
8958
+ playlistPath
8959
+ ];
8960
+ const spawner = spawnImplementation ?? spawn;
8961
+ let ffmpegProcess;
8962
+ try {
8963
+ ffmpegProcess = spawner("ffmpeg", args, { stdio: [
8964
+ "ignore",
8965
+ "pipe",
8966
+ "pipe"
8967
+ ] });
8968
+ } catch (error) {
8969
+ logger.error(`Failed to spawn ffmpeg for ${fileId}:`, error);
8970
+ throw new Error("Couldn't prepare this file for casting. Check that ffmpeg is installed.", { cause: error });
8971
+ }
8972
+ ffmpegProcess.on("error", (error) => {
8973
+ logger.error(`ffmpeg process error for ${fileId}:`, error);
8974
+ });
8975
+ ffmpegProcess.on("exit", (code) => {
8976
+ logger.info(`ffmpeg exited for ${fileId} with code ${code}`);
8977
+ });
8978
+ const readyPromise = waitForPlaylist(playlistPath, 3e4);
8979
+ const session = {
8980
+ fileId,
8981
+ ffmpegProcess,
8982
+ lastAccessedAt: Date.now(),
8983
+ playlistPath,
8984
+ tempDir,
8985
+ readyPromise
8986
+ };
8987
+ sessions.set(key, session);
8988
+ return session;
8989
+ }
8990
+ function stopSession(key) {
8991
+ const session = sessions.get(key);
8992
+ if (!session) return;
8993
+ if (session.ffmpegProcess && session.ffmpegProcess.exitCode === null) try {
8994
+ session.ffmpegProcess.kill("SIGTERM");
8995
+ } catch (error) {
8996
+ logger.warn(`Failed to kill ffmpeg for ${session.fileId}:`, error);
8997
+ }
8998
+ try {
8999
+ rmSync(session.tempDir, {
9000
+ recursive: true,
9001
+ force: true
9002
+ });
9003
+ } catch (error) {
9004
+ logger.warn(`Failed to remove temp dir ${session.tempDir}:`, error);
9005
+ }
9006
+ sessions.delete(key);
9007
+ }
9008
+ function reapIdleSessions(now = Date.now()) {
9009
+ for (const [key, session] of sessions.entries()) if (now - session.lastAccessedAt > reapIdleMs) {
9010
+ logger.info(`Reaping idle transcode session ${key}`);
9011
+ stopSession(key);
9012
+ }
9013
+ }
9014
+ if (typeof setInterval !== "undefined" && process.env.NODE_ENV !== "test") setInterval(() => reapIdleSessions(), reaperIntervalMs).unref?.();
9015
+ async function resolveFile(fileId) {
9016
+ if (fileId.startsWith("episode-")) {
9017
+ const episodeId = parseInt(fileId.slice(8), 10);
9018
+ if (isNaN(episodeId)) return null;
9019
+ const [episode] = await db.select().from(episodes).where(eq(episodes.id, episodeId)).limit(1);
9020
+ if (!episode) return null;
9021
+ return { filePath: episode.filePath };
9022
+ }
9023
+ if (fileId.startsWith("movie-")) {
9024
+ const movieId = parseInt(fileId.slice(6), 10);
9025
+ if (isNaN(movieId)) return null;
9026
+ const [movie] = await db.select().from(movies).where(eq(movies.id, movieId)).limit(1);
9027
+ if (!movie) return null;
9028
+ return { filePath: movie.filePath };
9029
+ }
9030
+ return null;
9031
+ }
9032
+ const transcodeRoutes = new Hono();
9033
+ transcodeRoutes.get("/:fileId/index.m3u8", async (context) => {
9034
+ const fileId = context.req.param("fileId");
9035
+ const profileIdRaw = context.req.header("x-jukebox-profile-id") ?? "0";
9036
+ const profileId = parseInt(profileIdRaw, 10);
9037
+ const resolved = await resolveFile(fileId);
9038
+ if (!resolved) return context.json({ error: { message: "File not found" } }, 404);
9039
+ if (!existsSync(resolved.filePath)) return context.json({ error: { message: "Video file not found" } }, 404);
9040
+ try {
9041
+ const session = startTranscode({
9042
+ fileId,
9043
+ filePath: resolved.filePath,
9044
+ profileId: isNaN(profileId) ? 0 : profileId
9045
+ });
9046
+ session.lastAccessedAt = Date.now();
9047
+ await session.readyPromise;
9048
+ const playlistStream = createReadStream(session.playlistPath);
9049
+ const webStream = Readable.toWeb(playlistStream);
9050
+ return new Response(webStream, { headers: {
9051
+ "Content-Type": "application/vnd.apple.mpegurl",
9052
+ "Cache-Control": "no-store"
9053
+ } });
9054
+ } catch (error) {
9055
+ const message = error instanceof Error ? error.message : "Couldn't prepare this file for casting. Check that ffmpeg is installed.";
9056
+ return context.json({ error: { message } }, 500);
9057
+ }
9058
+ });
9059
+ transcodeRoutes.get("/:fileId/:segment", async (context) => {
9060
+ const fileId = context.req.param("fileId");
9061
+ const segment = context.req.param("segment");
9062
+ const profileIdRaw = context.req.header("x-jukebox-profile-id") ?? "0";
9063
+ const profileId = parseInt(profileIdRaw, 10);
9064
+ const key = sessionKey(fileId, isNaN(profileId) ? 0 : profileId);
9065
+ const session = sessions.get(key);
9066
+ if (!session) return context.json({ error: { message: "Transcode session not found" } }, 404);
9067
+ if (!/^segment-\d+\.ts$/.test(segment)) return context.json({ error: { message: "Invalid segment" } }, 400);
9068
+ const segmentPath = path.join(session.tempDir, segment);
9069
+ const startedAt = Date.now();
9070
+ while (!existsSync(segmentPath) && Date.now() - startedAt < 15e3) await new Promise((resolve) => setTimeout(resolve, 200));
9071
+ if (!existsSync(segmentPath)) return context.json({ error: { message: "Segment not ready" } }, 404);
9072
+ session.lastAccessedAt = Date.now();
9073
+ const size = statSync(segmentPath).size;
9074
+ const nodeStream = createReadStream(segmentPath);
9075
+ const webStream = Readable.toWeb(nodeStream);
9076
+ return new Response(webStream, { headers: {
9077
+ "Content-Type": "video/mp2t",
9078
+ "Content-Length": String(size),
9079
+ "Cache-Control": "no-store"
9080
+ } });
9081
+ });
9082
+ try {
9083
+ const root = getTranscodeRoot();
9084
+ if (existsSync(root)) for (const entry of readdirSync(root)) rmSync(path.join(root, entry), {
9085
+ recursive: true,
9086
+ force: true
9087
+ });
9088
+ } catch (error) {
9089
+ logger.warn("Failed to clear transcode root on boot:", error);
9090
+ }
9091
+ //#endregion
9092
+ //#region src/api/routes/up-next.ts
9093
+ const upNextRoutes = new Hono();
9094
+ upNextRoutes.get("/", async (context) => {
9095
+ const profileId = context.get("profileId");
9096
+ const episodeProgressRows = await db.select({
9097
+ currentTime: watchProgress.currentTime,
9098
+ duration: watchProgress.duration,
9099
+ episode: episodes,
9100
+ updatedAt: watchProgress.updatedAt
9101
+ }).from(watchProgress).innerJoin(episodes, eq(watchProgress.episodeId, episodes.id)).where(eq(watchProgress.profileId, profileId)).orderBy(desc(watchProgress.updatedAt));
9102
+ const latestRowByShow = /* @__PURE__ */ new Map();
9103
+ for (const row of episodeProgressRows) if (!latestRowByShow.has(row.episode.showId)) latestRowByShow.set(row.episode.showId, row);
9104
+ const items = [];
9105
+ for (const [showId, row] of latestRowByShow.entries()) {
9106
+ if (!((row.duration && row.duration > 0 ? row.currentTime / row.duration : 0) >= .9)) continue;
9107
+ const laterEpisodes = (await db.select().from(episodes).where(eq(episodes.showId, showId)).orderBy(episodes.seasonNumber, episodes.episodeNumber)).filter((candidate) => {
9108
+ if (candidate.seasonNumber > row.episode.seasonNumber) return true;
9109
+ if (candidate.seasonNumber < row.episode.seasonNumber) return false;
9110
+ return candidate.episodeNumber > row.episode.episodeNumber;
9111
+ });
9112
+ if (laterEpisodes.length === 0) continue;
9113
+ const laterIds = new Set(laterEpisodes.map((candidate) => candidate.id));
9114
+ const laterProgress = episodeProgressRows.filter((progressRow) => progressRow.episode.id !== row.episode.id && laterIds.has(progressRow.episode.id));
9115
+ const progressByEpisodeId = /* @__PURE__ */ new Map();
9116
+ for (const progressRow of laterProgress) progressByEpisodeId.set(progressRow.episode.id, {
9117
+ currentTime: progressRow.currentTime,
9118
+ duration: progressRow.duration
9119
+ });
9120
+ const nextEpisode = laterEpisodes.find((candidate) => {
9121
+ const progress = progressByEpisodeId.get(candidate.id);
9122
+ if (!progress) return true;
9123
+ if (!progress.duration || progress.duration <= 0) return true;
9124
+ return progress.currentTime / progress.duration < watchedThreshold;
9125
+ });
9126
+ if (!nextEpisode) continue;
9127
+ const [show] = await db.select().from(shows).where(eq(shows.id, showId)).limit(1);
9128
+ if (!show) continue;
9129
+ items.push({
9130
+ episode: nextEpisode,
9131
+ show,
9132
+ lastWatchedAt: row.updatedAt.toISOString()
9133
+ });
9134
+ }
9135
+ items.sort((a, b) => new Date(b.lastWatchedAt).getTime() - new Date(a.lastWatchedAt).getTime());
9136
+ return context.json(items.slice(0, 20));
9137
+ });
9138
+ //#endregion
7955
9139
  //#region src/api/index.ts
7956
9140
  const app = new Hono();
7957
- app.use("*", logger());
9141
+ app.use("*", logger$1());
9142
+ app.use("/api/*", authMiddleware);
9143
+ app.use("/api/*", profileMiddleware);
7958
9144
  app.onError((error, context) => {
7959
9145
  console.error("Unhandled error:", error);
7960
9146
  const message = error instanceof Error ? error.message : "An unexpected error occurred";
7961
9147
  return context.json({ error: { message } }, 500);
7962
9148
  });
9149
+ app.route("/api/auth", authRoutes);
7963
9150
  app.route("/api/hello", helloRoutes);
7964
9151
  app.route("/api/library/shows", showRoutes);
9152
+ app.route("/api/library/up-next", upNextRoutes);
7965
9153
  app.route("/api/library", libraryRoutes);
7966
9154
  app.route("/api/progress/episode", episodeProgressRoutes);
7967
9155
  app.route("/api/progress", progressRoutes);
7968
9156
  app.route("/api/scan", scanRoutes);
9157
+ app.route("/api/profiles", profileRoutes);
9158
+ app.route("/api/favorites", favoriteRoutes);
7969
9159
  app.route("/api/setup", setupRoutes);
7970
9160
  app.route("/api/stream/episode", episodeStreamRoutes);
7971
9161
  app.route("/api/stream", streamRoutes);
9162
+ app.route("/api/transcode", transcodeRoutes);
7972
9163
  app.get("/api", (c) => c.json({ message: "Jukebox API" }));
7973
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
7974
- const clientDir = path.resolve(__dirname, "../client");
9164
+ const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
9165
+ const clientDir = path.resolve(__dirname$1, "../client");
7975
9166
  function setupStaticServing() {
7976
9167
  app.use("*", serveStatic({
7977
9168
  root: clientDir,
@@ -8000,7 +9191,9 @@ function setupViteProxy(vitePort) {
8000
9191
  if (c.req.path.startsWith("/api")) return next();
8001
9192
  const viteUrl = `http://localhost:${vitePort}${c.req.path}`;
8002
9193
  const headers = new Headers();
8003
- for (const [key, value] of c.req.raw.headers.entries()) if (!hopByHopHeaders.has(key.toLowerCase())) headers.set(key, value);
9194
+ c.req.raw.headers.forEach((value, key) => {
9195
+ if (!hopByHopHeaders.has(key.toLowerCase())) headers.set(key, value);
9196
+ });
8004
9197
  try {
8005
9198
  const response = await fetch(viteUrl, {
8006
9199
  method: c.req.method,
@@ -8021,9 +9214,20 @@ function setupViteProxy(vitePort) {
8021
9214
  const port = process.env.PORT ? Number(process.env.PORT) : 1990;
8022
9215
  const vitePort = 5173;
8023
9216
  const isDevelopment = process.env.NODE_ENV !== "production";
9217
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9218
+ function readPackageVersion() {
9219
+ const candidates = [path.resolve(__dirname, "../../package.json"), path.resolve(__dirname, "../package.json")];
9220
+ for (const candidate of candidates) try {
9221
+ const content = JSON.parse(readFileSync(candidate, "utf-8"));
9222
+ if (content.name === "jukebox-media-server" && content.version) return content.version;
9223
+ } catch {}
9224
+ return "unknown";
9225
+ }
9226
+ const version = readPackageVersion();
8024
9227
  let viteServer = null;
8025
9228
  if (isDevelopment) {
8026
- viteServer = await createServer$1({
9229
+ const { createServer } = await import("vite");
9230
+ viteServer = await createServer({
8027
9231
  configFile: "./vite.config.ts",
8028
9232
  server: {
8029
9233
  port: vitePort,
@@ -8031,15 +9235,30 @@ if (isDevelopment) {
8031
9235
  }
8032
9236
  });
8033
9237
  await viteServer.listen();
8034
- console.log(`Vite dev server running at http://localhost:${vitePort}`);
8035
9238
  setupViteProxy(vitePort);
8036
9239
  } else setupStaticServing();
8037
9240
  const server = serve({
8038
9241
  fetch: app.fetch,
8039
9242
  port
8040
9243
  }, (info) => {
8041
- console.log(`Jukebox running at http://localhost:${info.port}`);
9244
+ printWelcome(info.port);
8042
9245
  });
9246
+ function printWelcome(boundPort) {
9247
+ const url = `http://localhost:${boundPort}`;
9248
+ const bold = "\x1B[1m";
9249
+ const cyan = "\x1B[36m";
9250
+ const dim = "\x1B[2m";
9251
+ const reset = "\x1B[0m";
9252
+ const lines = [
9253
+ "",
9254
+ ` ${bold}Jukebox${reset} ${dim}v${version}${reset}`,
9255
+ "",
9256
+ ` ${cyan}➜${reset} Open ${cyan}${url}${reset} in your browser`,
9257
+ ` ${dim}Press Ctrl+C to stop${reset}`,
9258
+ ""
9259
+ ];
9260
+ console.log(lines.join("\n"));
9261
+ }
8043
9262
  function shutdown(signal) {
8044
9263
  console.log(`\nReceived ${signal}, shutting down...`);
8045
9264
  setTimeout(() => process.exit(1), 2e3).unref();