watchparty/server/src/types.ts
Nicolas 7bf8197bfe
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Merge remote-tracking branch 'origin/master' into feature/sticky-search-and-music-seek
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 07:20:57 +02:00

468 lines
19 KiB
TypeScript

/**
* Shared server-side types. Kept in one file rather than colocated with
* each service, since several of these (Session, ItemDto, Party*) are used
* by three or more services and colocating would just mean everyone
* importing from whichever service happened to "own" the type first.
*
* Not shared with the client build (client/src/types.ts has its own,
* overlapping copies of the DTO shapes) - the two projects compile
* independently (tsc here, esbuild there), and a real shared package would
* be a bigger structural commitment than this app's size justifies. A
* handful of duplicated interfaces across the client/server boundary is a
* normal, accepted trade-off for that - see docs/architecture.md.
*/
import type { Server as IoServer, Socket as IoSocket } from 'socket.io';
// --- Realtime (Socket.IO) event maps ---------------------------------------
// Typed once here rather than left as bare strings at every .emit()/.on()
// call site - see SocketGateway, MusicQueueService (which broadcasts
// "queueUpdated" directly, independent of SocketGateway's own handlers).
export interface ClientToServerEvents {
subscribeLobby: () => void;
/** deviceId: persisted client-side (see client/src/deviceId.ts) - lets
* the "join" handler tell "this same account, same device reconnecting"
* apart from "this same account, a genuinely different device" (see
* SocketGateway's own comment). force: true re-sends this after the
* user confirmed a "deviceConflict" takeover. */
join: (msg: { partyId: string; deviceId: string; force?: boolean }) => void;
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
next: () => void;
voteSkip: () => void;
musicPlaystate: (msg: { isPaused: boolean }) => void;
/** No host check, same as "musicPlaystate" - see MusicQueueService's own
* class comment on why a seek gets the same permissive treatment as
* pause/resume (reversible, doesn't change what's playing) rather than
* "voteSkip"'s majority-vote gate (which does). */
musicSeek: (msg: { positionSeconds: number }) => void;
requestPause: () => void;
requestResume: () => void;
/** Host-only (checked server-side exactly like "next") - picks which
* audio/subtitle combination the party's *shared* burn-in stream uses,
* see TranscodeService. A null subtitleIndex clears it, dropping the
* party back to its normal party-shared Emby stream (PartyStreamService -
* also shared, just proxied rather than transcoded by us). Not for
* text subtitles - see "setTextSubtitle" below for those. Setting a
* real subtitleIndex here clears party.textSubtitleIndex (mutually
* exclusive - only one subtitle can be showing at once). */
setBurnInSubtitle: (msg: { subtitleIndex: string | null; audioIndex?: string }) => void;
/** Host-only, video-only (checked server-side exactly like "next") -
* picks (or clears) a *text* subtitle for the whole party, now that
* subtitle choice in general is party-wide rather than each viewer's
* own local pick (see docs/architecture.md - it wasn't at first, but
* that turned out to be the wrong call once people actually used
* this together). Every member still renders it themselves as a
* native <track> - this only syncs *which* one, not the rendering
* itself, so there's no server-side transcoding cost the way burn-in
* has. Setting a real subtitleIndex here clears party.burnInSubtitleIndex/
* burnInAudioIndex (mutually exclusive, same as above). */
setTextSubtitle: (msg: { subtitleIndex: string | null }) => void;
/** Host-only, video-only (checked server-side exactly like "next") -
* picks the audio track/quality the party's *shared* normal stream
* uses (PartyStreamService) - see docs/architecture.md's "One shared
* Emby stream per party" section for why this had to become a
* party-wide choice instead of each viewer's own: there's only ever
* one stream now, so there's only room for one set of encoding
* parameters behind it. Either field null/omitted means "Emby's own
* default" - same as an untouched picker meant before this. */
setStreamOptions: (msg: { audioStreamIndex?: string | null; quality?: string | null }) => void;
leave: () => void;
}
/** The socket "members" broadcast's shape - deliberately smaller than
* PartyPublicState.members below (no isPaused/positionTicks): that extra
* detail is REST-only (a follower's player only ever reads "playstate"
* events for position, never the member list, for that). */
export interface SocketMemberDto {
userId: string;
userName: string;
isHost: boolean;
connected: boolean;
}
export interface ServerToClientEvents {
parties: (parties: LobbyPartyDto[]) => void;
members: (members: SocketMemberDto[]) => void;
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
itemChanged: (msg: {
itemId: string;
itemName: string;
itemImageUrl: string;
queueIndex: number;
queueLength: number;
hasNext: boolean;
}) => void;
queueUpdated: (state: MusicPublicState) => void;
hostChanged: () => void;
pauseRequested: (msg: { userId: string; userName: string }) => void;
resumeRequested: (msg: { userId: string; userName: string }) => void;
/** Sent only to the joining socket itself (never broadcast) when this
* account is already actively connected from a different deviceId -
* see SocketGateway's "join" handler. The socket stays connected but
* inert (not attached to the party) until the client either re-sends
* "join" with force:true or gives up. */
deviceConflict: (msg: { partyId: string }) => void;
/** Broadcast to the whole party room whenever the host changes (or
* clears) the party's shared burn-in subtitle/audio choice - see
* "setBurnInSubtitle" above and TranscodeService. Mirrors party.burnIn*
* below, which is what a fresh "getParty" (a follower joining
* mid-party) reads instead - this event is only for someone already
* connected when it changes live. */
burnInChanged: (msg: { subtitleIndex: string | null; audioIndex: string | null }) => void;
/** Broadcast to the whole party room whenever the host changes (or
* clears) the party's *text* subtitle choice - see "setTextSubtitle"
* above. Mirrors party.textSubtitleIndex below, which is what a
* fresh "getParty" (a follower joining mid-party) reads instead. */
textSubtitleChanged: (msg: { subtitleIndex: string | null }) => void;
/** Broadcast to the whole party room whenever the host changes the
* party's shared stream's audio track/quality - see
* "setStreamOptions" above and PartyStreamService. Mirrors
* party.streamAudioIndex/streamQuality below, which is what a fresh
* "getParty" (a follower joining mid-party) reads instead - this
* event is only for someone already connected when it changes live. */
streamOptionsChanged: (msg: { audioStreamIndex: string | null; quality: string | null }) => void;
errorMessage: (msg: { message: string }) => void;
}
export type AppSocket = IoSocket<ClientToServerEvents, ServerToClientEvents>;
export type AppServer = IoServer<ClientToServerEvents, ServerToClientEvents>;
// --- Auth / session -------------------------------------------------------
export interface EmbySession {
embyToken: string;
userId: string;
userName: string;
serverId: string;
createdUtc: number;
}
// Augments Express's own Request type (declaration merging) so
// `req.embySession` is known everywhere without a separate "AuthedRequest"
// type at every route - set once by requireSession in index.ts.
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
embySession?: EmbySession;
}
}
}
// --- Catalog ---------------------------------------------------------------
/** Emby's own item shape, as returned by /Users/{id}/Items and friends -
* only the fields this app actually reads, not the full Emby schema. */
export interface EmbyItem {
Id: string;
Name: string;
Overview?: string;
ProductionYear?: number;
Type?: string;
SeriesId?: string;
SeriesName?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
RunTimeTicks?: number;
Album?: string;
AlbumArtist?: string;
Artists?: string[];
/** The parent album's own id - only meaningful for Type: 'Audio'. See
* CatalogService.imageUrlFor: confirmed against a real server that an
* individual track essentially never has its own Primary image (every
* one sampled 500'd), only its album does - this is how an image URL
* falls back to the album's art instead. */
AlbumId?: string;
}
/** This app's own DTO shape - what toItemDto() in CatalogService produces,
* sent to the client for every item type (movie/episode/series/artist/
* album/track/musicvideo alike). Track-specific fields are present but
* meaningless for anything else. */
export interface ItemDto {
id: string;
name: string;
overview?: string;
year?: number;
type?: string;
canWatchParty: boolean;
imageUrl: string;
embyUrl: string;
durationTicks: number | null;
indexNumber: number | null;
artistName: string | null;
albumName: string | null;
}
export interface LibraryDto {
id: string;
name: string;
collectionType: string;
}
export interface SeasonDto {
id: string;
name: string;
indexNumber?: number;
}
export interface PlaybackTrackOption {
index: number;
label: string;
// Subtitle tracks only (undefined for audio - the concept doesn't apply).
// false means this is an image/bitmap subtitle (PGS/VobSub/DVB, typically
// a Blu-ray/DVD rip) - see IMAGE_SUBTITLE_CODECS's own comment. This app
// renders subtitles as a native <track> (WebVTT, converted server-side
// from whatever the source format is - see CatalogService.proxySubtitle),
// which needs actual text; a bitmap has none to extract.
isTextBased?: boolean;
}
export interface PlaybackOptionsDto {
audioTracks: PlaybackTrackOption[];
subtitleTracks: PlaybackTrackOption[];
}
/**
* Emby's own native intro/credits detection (a library's own
* EnableMarkerDetection option, run by its "Detect Episode Intros"
* scheduled task - confirmed directly against a real server's
* /openapi.json: MarkerType is one of Chapter/IntroStart/IntroEnd/
* CreditsStart, mixed into the same Chapters array as the auto-generated
* per-5-minutes ones). Every field is nullable, independently - the task
* may not have reached this item yet, the library may not have the
* feature enabled at all (it's per-library, not global), or a real
* cold-open episode may genuinely have no IntroStart/IntroEnd. None of
* that is an error - see CatalogService.getPlaybackMarkers - a caller
* just checks each field for null before offering the matching skip
* button. */
export interface PlaybackMarkersDto {
introStartTicks: number | null;
introEndTicks: number | null;
creditsStartTicks: number | null;
}
// --- Party (video) -----------------------------------------------------------
/** One entry in a video party's fixed, pre-built queue (a movie/standalone
* episode is just a one-item queue) - see PartyService.create. */
export interface QueueItem {
itemId: string;
itemName: string;
itemImageUrl: string;
}
export type MediaType = 'video' | 'music';
export interface PartyMember {
userId: string;
userName: string;
connection: AppSocket | null;
/** Whichever deviceId last successfully attached `connection` - null
* until the first real socket "join" (a member freshly created by the
* REST join/create routes has no live connection yet, so nothing to
* compare a new one against). See SocketGateway's "join" handler. */
deviceId: string | null;
/** This member's own Emby access token - set right after they
* create/join (REST) and re-confirmed on every socket "join" (see
* index.ts / SocketGateway). Needed so the periodic Emby-playstate
* sweep (index.ts) can report *their own* progress under *their own*
* Emby account, not just the host's - see PlaystateService and
* docs/architecture.md. Never sent to the client - see
* PartyService.toPublicState, which never includes it. Null until
* the first of those two things happens (mirrors deviceId's own
* null-until-known convention). */
embyToken: string | null;
isPaused: boolean;
positionTicks: number;
lastUpdatedUtc: number;
lastPauseRequestUtc?: number;
lastResumeRequestUtc?: number;
/** This member's own live Emby Sessions-API entry, if one's currently
* open - see EmbySessionService. Null whenever they don't have one
* (not connected/no token yet, or the periodic sweep hasn't started
* one for their current item yet). `itemId` is what the sweep compares
* against on each tick to notice an item change (stop the old
* session, start a fresh one) - `mediaSourceId`/`playSessionId` are
* cached here specifically so a Progress report can reuse them
* without renegotiating PlaybackInfo on every single tick. */
embySession: EmbyPlaybackSession | null;
}
/** See PartyMember.embySession's own comment. */
export interface EmbyPlaybackSession {
itemId: string;
mediaSourceId: string;
playSessionId: string;
}
/** The full internal party record - never sent to the client as-is, see
* PartyService.toPublicState for the shape that actually goes out. */
export interface Party {
id: string;
mediaType: MediaType;
itemId: string;
itemName: string;
itemImageUrl: string;
queue: QueueItem[];
queueIndex: number;
hostUserId: string;
createdUtc: number;
lastActivityUtc: number;
members: Map<string, PartyMember>;
/** Only present once MusicQueueService.init() has run - i.e. mediaType
* === 'music'. Owned and shaped entirely by MusicQueueService; every
* other service treats this as an opaque bag it just carries around. */
music?: MusicState;
/** Video only - the host's current pick for the party's *shared*
* burn-in stream (see TranscodeService), null when nobody's picked one
* and the party is just using its normal party-shared Emby stream
* instead (PartyStreamService - also shared, just proxied rather than
* transcoded by us). Both null or both set - never one without the
* other, see SocketGateway's "setBurnInSubtitle" handler. Persisted
* here (not just broadcast live) so a follower joining mid-party, or
* a page reload, picks up the party's current choice from a plain
* "getParty" - same reason itemId/queueIndex live here too. */
burnInSubtitleIndex: string | null;
burnInAudioIndex: string | null;
/** Video only - the host's current pick for a *text* subtitle, party-
* wide - see SocketGateway's "setTextSubtitle" handler. Mutually
* exclusive with burnInSubtitleIndex (picking one clears the other -
* only one subtitle can be showing at once). Every member still
* renders it themselves as a native <track> - this only syncs
* *which* one, no server-side transcoding involved, unlike burn-in. */
textSubtitleIndex: string | null;
/** Video only - the host's current pick for the party's normal
* *shared* Emby stream (PartyStreamService) - null means "Emby's own
* default", same as an untouched picker meant before streams were
* shared. There is only ever one of these now (not one per viewer),
* so - like burnIn* above - this is party-wide, host-set state, not
* something each client tracks locally; see SocketGateway's
* "setStreamOptions" handler. Meaningless for a music party -
* MusicBar.ts never had this picker in the first place. */
streamAudioIndex: string | null;
streamQuality: string | null;
}
export interface PublicMember {
userId: string;
userName: string;
isHost: boolean;
isPaused: boolean;
positionTicks: number;
connected: boolean;
}
/** What GET /api/parties/:id and POST /api/parties actually return - the
* video queue fields (hasNext/queueLength/queueIndex) are meaningless for
* a music party, which carries `music` instead. */
export interface PartyPublicState {
partyId: string;
mediaType: MediaType;
itemId: string;
itemName: string;
itemImageUrl: string;
createdUtc: number;
hasNext: boolean;
queueLength: number;
queueIndex: number;
hostUserName: string | null;
memberCount: number;
youAreHost: boolean;
members: PublicMember[];
music: MusicPublicState | null;
burnInSubtitleIndex: string | null;
burnInAudioIndex: string | null;
textSubtitleIndex: string | null;
streamAudioIndex: string | null;
streamQuality: string | null;
}
export interface LobbyPartyDto {
partyId: string;
mediaType: MediaType;
itemId: string;
itemName: string;
itemImageUrl: string;
hostUserName: string | null;
memberCount: number;
createdUtc: number; // music parties (and any party's own age) still key off this
// Video only - null for music (no single "elapsed" makes sense across a
// rotating queue of tracks the way it does for one movie/episode). The
// host's own last-reported position/pause state (same values their
// "playstate" heartbeat already tracks - see SocketGateway) plus the
// moment they were reported, so the client can tick a live, pause-aware
// "elapsed" label itself between broadcasts instead of the home page
// just counting wall-clock time since createdUtc regardless of whether
// the movie was ever actually playing that whole time.
positionTicks: number | null;
isPaused: boolean | null;
positionUpdatedUtc: number | null;
}
export interface LeaveResult {
hostChanged: boolean;
newHostUserId: string | null;
destroyed: boolean;
}
// --- Music (participative queue) --------------------------------------------
/** One track in a music party's shared, mutable queue - see
* MusicQueueService. Distinct from QueueItem above: that one is fixed and
* built once server-side, this one grows live as members add to it. */
export interface MusicTrack {
itemId: string;
itemName: string;
artistName: string | null;
albumName: string | null;
itemImageUrl: string;
durationSeconds: number | null;
addedByUserId: string;
addedByUserName: string;
}
/** What a client actually sends when adding a track (POST
* /api/parties/:id/tracks) - Emby-tick-shaped, converted to
* MusicTrack.durationSeconds once by MusicQueueService.addTrack. */
export interface MusicTrackInput {
itemId: string;
itemName: string;
artistName?: string | null;
albumName?: string | null;
itemImageUrl: string;
durationTicks?: number | null;
}
export interface MusicCurrentTrack {
track: MusicTrack;
startedUtc: number;
isPaused: boolean;
/** Elapsed ms at the moment it was paused - null while playing. Combined
* with `isPaused`, this is the only state MusicQueueService.getElapsedSeconds
* needs; there is no separate ticking "position" stored anywhere. */
pausedElapsedMs: number | null;
}
/** party.music - owned entirely by MusicQueueService (see its class
* comment for why this is a separate service rather than living in
* PartyService alongside the video queue fields). */
export interface MusicState {
queue: MusicTrack[];
current: MusicCurrentTrack | null;
skipVotes: Set<string>;
advanceTimer: NodeJS.Timeout | null;
}
export interface MusicCurrentPublic extends MusicTrack {
isPaused: boolean;
elapsedSeconds: number;
}
/** The "queueUpdated" broadcast payload / party.music's public shape. */
export interface MusicPublicState {
queue: MusicTrack[];
current: MusicCurrentPublic | null;
skipVotes: number;
}