All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
Full rewrite, requested directly. Two independent decisions since TypeScript can't compile itself away at runtime - "no build step" and "TypeScript" are mutually exclusive, not a small addition to the existing setup: - Server: plain tsc (no bundling need - Node resolves its own requires). server/dist mirrors server/src 1:1. - Client: esbuild (does need bundling) - picked over a heavier tool (Vite, webpack) since this app's whole client is a couple thousand lines; its built-in CSS-from-JS bundling and a `text` loader for importing an HTML template as a string cover everything needed via a ~30-line hand-written build script (client/build.mjs), not a config file for something built for much bigger apps. Both projects compile independently (no shared-types package - considered and rejected as more structural commitment than this app's size justifies; server/src/types.ts and client/src/types.ts have their own overlapping DTO copies instead). server/'s `npm run dev` uses tsx for a compile-free dev loop; `npm run build` (tsc) is what server/Dockerfile's build stage actually runs. client/'s `npm run watch` is esbuild's own watch mode. Server: one singleton service class per feature, generalizing the PartyManager/MusicQueueService pattern that already existed, rather than route handlers with logic inline: - AuthService (from embyAuth.js), CatalogService (NEW - previously ~15 route handlers' worth of Emby queries/DTO shaping inlined directly in index.js), PartyService (renamed from PartyManager), MusicQueueService (ported as-is, already service-shaped), SocketGateway (from socketHandler.js), EmbyClient (from embyProxy.js - low-level plumbing, not called a "service" since it has no state/feature of its own). - index.ts is now thin wiring only: every route reads the session, calls one service method, returns the result. - Real error-handling pass, not just a port: Express 4 doesn't catch a rejected Promise from an async handler on its own - a new asyncRoute() wrapper + a single error-handling middleware replace what used to be zero handling for a request that outright throws. CatalogService methods throw a CatalogFetchError (own HTTP status - getRawItem now preserves Emby's real 404 instead of a blanket 502) rather than touching res directly. Client: one folder per view (src/views/<name>/<Name>.ts + .html + .css) instead of one .js file with the markup inline as a template literal. A view's static shell moved to its .html (esbuild's text loader); per-item dynamic content (tile grids, episode/track lists) stays as template -literal-returning functions in the .ts, same as before. Genuinely shared CSS (tiles, the presence avatar, the series/album detail-header pattern, track-list rows) lives under src/styles/, imported by whichever views need it; src/styles/base.css holds the global primitives, imported once by app.ts. Deliberately not pushed to zero duplication/renaming shared classes across views for tidiness alone - not worth the risk in a change already this large. Player.ts keeps its pre-existing internal shape (the /party/:id dispatcher + the video renderer in one file, importing renderMusicPlayer from the sibling folder) - that pattern predates this rewrite and only needed types, not restructuring. Two real things the port itself caught, not just carried over: - A type bug: porting SocketGateway's "members" broadcast, reusing PartyService's PublicMember interface would have type-checked fine but was the wrong (larger) shape - the socket broadcast never included isPaused/positionTicks, only the REST response did. Caught by comparing against the original JS line by line, fixed with a dedicated SocketMemberDto type. - A real Express+TypeScript friction point: noUncheckedIndexedAccess (enabled in both tsconfigs - genuinely useful elsewhere, e.g. PartyService.advanceQueue's array indexing) makes every req.params.xxx read a type error, since Express's index-signature typing doesn't know a matched route's required :name segment is always present. Resolved once with a param(req, name) helper rather than 15 routes' worth of scattered assertions. Verified: both projects type-check cleanly in strict mode (tsc --noEmit - not just "esbuild didn't complain", which it wouldn't even with type errors present, since it transpiles without checking). Server: every unit/integration test from the JS version (party lifecycle logging, the music queue's timer/vote/pause behavior incl. real setTimeout auto-advance, the socket integration through SocketGateway+PartyService+ MusicQueueService together, the library-type filter, the playback -override forcing) re-run against the compiled server/dist output - identical results; the compiled server was actually started and confirmed listening cleanly. Client: since esbuild bundles everything into one app.js (no more individually-importable view modules), testing moved to driving the real compiled bundle through its actual boot -> login -> router -> view chain (mocked fetch/Socket.IO/hls.js, real clicks/hash navigation) - arguably more realistic than the old per-module approach. Confirmed this way: login, the sidebar, home's carousels, a video party (presence, host controls, leave), the full music hierarchy, a music party (presence, vote-skip, pause toggle, the inline browse-and-add panel), and the series page - all through client/dist's actual output, no thrown errors anywhere. Not verified: an actual `docker build` of the new multi-stage Dockerfile - this dev environment has no Docker at all. The build stage runs the exact npm run build commands already confirmed working locally, just inside node:20-alpine - the main remaining risk is something environment -specific, not the build commands themselves, but a real build is still the first thing to try after this lands. docs/architecture.md has the full writeup (new "TypeScript and a service-oriented architecture" section, plus a corrected top-level "Components" overview that had gone stale even before this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
'use strict';
|
|
|
|
import crypto from 'crypto';
|
|
import type { Request } from 'express';
|
|
import { EMBY_SERVER_URL } from '../config';
|
|
import type { EmbySession } from '../types';
|
|
|
|
export const SESSION_COOKIE = 'wp_session';
|
|
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
|
|
function authHeaderValue(): string {
|
|
return 'MediaBrowser Client="WatchParty", Device="Server", DeviceId="watchparty-server", Version="0.1.0"';
|
|
}
|
|
|
|
interface EmbyAuthResponse {
|
|
AccessToken: string;
|
|
ServerId: string;
|
|
User: { Id: string; Name: string };
|
|
}
|
|
|
|
/**
|
|
* Server-side Emby login + in-memory session store. The browser only ever
|
|
* sends its username/password once, to us; we forward that to Emby, and
|
|
* from then on the browser only holds an opaque session id (cookie) -
|
|
* never Emby's own access token. This is what makes "the server proxies
|
|
* everything" possible: the browser has nothing it could use to talk to
|
|
* Emby even if it tried. See docs/architecture.md.
|
|
*
|
|
* Singleton, same shape as PartyService/MusicQueueService - sessions are
|
|
* in-memory, like party state, so a restart signs everyone out, which is
|
|
* fine (an Emby login is one click away).
|
|
*/
|
|
class AuthServiceClass {
|
|
private sessions = new Map<string, EmbySession>();
|
|
|
|
/** Returns a new session id, or null on bad credentials / unreachable Emby. */
|
|
async login(username: string, password: string): Promise<string | null> {
|
|
if (!EMBY_SERVER_URL) return null;
|
|
|
|
let res: globalThis.Response;
|
|
try {
|
|
res = await fetch(`${EMBY_SERVER_URL}/Users/AuthenticateByName`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Emby-Authorization': authHeaderValue()
|
|
},
|
|
body: JSON.stringify({ Username: username, Pw: password })
|
|
});
|
|
} catch (err) {
|
|
console.error('[watchparty] Emby login request failed:', (err as Error).message);
|
|
return null;
|
|
}
|
|
|
|
if (!res.ok) return null;
|
|
|
|
const data = (await res.json()) as EmbyAuthResponse;
|
|
const sessionId = crypto.randomUUID();
|
|
this.sessions.set(sessionId, {
|
|
embyToken: data.AccessToken,
|
|
userId: data.User.Id,
|
|
userName: data.User.Name,
|
|
serverId: data.ServerId, // needed to build "open in Emby" deep links
|
|
createdUtc: Date.now()
|
|
});
|
|
return sessionId;
|
|
}
|
|
|
|
getSession(sessionId: string): EmbySession | null {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return null;
|
|
if (Date.now() - session.createdUtc > SESSION_TTL_MS) {
|
|
this.sessions.delete(sessionId);
|
|
return null;
|
|
}
|
|
return session;
|
|
}
|
|
|
|
endSession(sessionId: string): void {
|
|
this.sessions.delete(sessionId);
|
|
}
|
|
|
|
parseCookies(cookieHeader: string | undefined): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
(cookieHeader || '').split(';').forEach((part) => {
|
|
const idx = part.indexOf('=');
|
|
if (idx === -1) return;
|
|
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/** Reads the caller's session from an Express request's cookie, or null. */
|
|
sessionFromRequest(req: Request): EmbySession | null {
|
|
const cookies = this.parseCookies(req.headers.cookie);
|
|
const id = cookies[SESSION_COOKIE];
|
|
return id ? this.getSession(id) : null;
|
|
}
|
|
}
|
|
|
|
export const AuthService = new AuthServiceClass();
|