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.
- package/README.md +17 -0
- package/bin/jukebox-media-server.js +2 -1
- package/dist/client/assets/{Watch-C2gUX-4m.js → Watch-kJkUCvFd.js} +31 -31
- package/dist/client/assets/index-1v1BzSVv.js +56 -0
- package/dist/client/assets/index-FPcl2l8F.css +1 -0
- package/dist/client/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/client/index.html +24 -4
- package/dist/client/{images/site.webmanifest → manifest.webmanifest} +9 -6
- package/dist/client/offline.html +107 -0
- package/dist/client/sw.js +1 -0
- package/dist/client/workbox-d3da0cfa.js +1 -0
- package/dist/server/index.js +1258 -39
- package/drizzle/0001_steady_puma.sql +65 -65
- package/drizzle/0002_sloppy_betty_ross.sql +8 -8
- package/drizzle/0003_public_speedball.sql +42 -0
- package/drizzle/0004_motionless_bug.sql +13 -0
- package/drizzle/meta/0002_snapshot.json +591 -591
- package/drizzle/meta/0003_snapshot.json +804 -0
- package/drizzle/meta/0004_snapshot.json +880 -0
- package/drizzle/meta/_journal.json +41 -27
- package/package.json +32 -2
- package/dist/client/assets/index-D_um8N7Q.js +0 -52
- package/dist/client/assets/index-DfXUcX5G.css +0 -1
package/dist/server/index.js
CHANGED
|
@@ -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$
|
|
6724
|
-
migrate(db, { migrationsFolder: path.resolve(__dirname$
|
|
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.
|
|
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$
|
|
6860
|
-
return c.json(movies$
|
|
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
|
|
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$
|
|
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$
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|