468 lines
19 KiB
TypeScript
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;
|
|
}
|