/** * 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; /** Host-only (checked server-side exactly like "next") - the last * queued item played to its end, so there's no "next" to send. Lets * the server count that final item as completed (PartyService * .completeCurrentItem) without advancing anything. */ itemEnded: () => 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 - 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; export type AppServer = IoServer; // --- 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; /** Emby's own sort key - articles ("The", "Le", "L'", ...) stripped, * diacritics/case normalized its own way. Only present when a caller's * `Fields` actually asks for it (getLibraryItems/getArtists do, for * the library alphabet index - see ItemDto.sortName and * CatalogService.toItemDto). */ SortName?: 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; /** The requesting user's own per-item state - Emby embeds it on every * item fetched with a userId, unasked (see PlaystateService's own * comment). Only `Played` is read, by the static watch party's * "missed items" computation (StaticPartyService). */ UserData?: { Played?: boolean }; } /** 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; /** Emby's own SortName when the caller requested it, the display name * otherwise (CatalogService.toItemDto always falls back) - same * "populated where it matters, harmless default elsewhere" shape as * durationTicks/indexNumber below. Only client/src/alphaIndex.ts reads * this today, to bucket a library by letter the same way Emby itself * already ordered it: Emby's SortName strips leading articles (e.g. * "Le Voyage" sorts as "Voyage"), so bucketing by the raw display name * instead put a "L" header in the middle of a "V" run - reported * directly as headers landing in no sensible order at all. */ sortName: 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 (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; /** 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 - 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; /** Set only for a live session launched from a static watch party (see * StaticPartyService) - absent for every ordinary party. */ staticLink?: StaticPartyLink; /** Kept out of the home page's live lobby list (SocketGateway * .lobbySnapshot) - a solo catch-up session of a static watch party. */ hiddenFromLobby?: boolean; /** The last item this party already reported as completed (see * PartyService.completeCurrentItem) - guards against an "itemEnded" * followed by a "next" on the same item counting it twice. */ lastCompletedItemId?: string | null; } /** How a live party relates to the static watch party it was launched * from - `group` is the group's own session (advances the group's * progress), `catchUp` is someone catching up on items they missed * (only marks those items watched for whoever was connected). */ export interface StaticPartyLink { staticPartyId: string; mode: 'group' | 'catchUp'; } 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; /** The static watch party this live session belongs to, if any - shown * as a badge on the home page's lobby card. */ staticPartyId: string | null; staticPartyName: string | null; } // --- Static (persistent) watch party ------------------------------------------ /** One member of a static watch party - persisted, independent of any * live connection (unlike PartyMember). */ export interface StaticPartyMember { userId: string; userName: string; joinedUtc: number; /** Items this member watched in-app: with the group, in a catch-up * session, or marked watched by hand. Emby's own Played flag is * combined with this at read time, never copied into it. */ watchedItemIds: string[]; lastSeenUtc: number; } /** The group's own progress through a static watch party's items. */ export interface StaticPartyGroupProgress { /** Index into `items` of the next item the group will watch. */ nextIndex: number; /** Where the group stopped inside items[nextIndex] - applied as the * host's starting position on the next session. */ resumePositionTicks: number; completedItemIds: string[]; lastSessionUtc: number | null; } /** A persistent watch party over a whole series or video playlist - see * StaticPartyService and spec/static-watch-party.md. */ export interface StaticParty { id: string; name: string; source: { type: 'series' | 'playlist'; sourceId: string }; imageUrl: string; createdByUserId: string; createdUtc: number; items: QueueItem[]; group: StaticPartyGroupProgress; members: Record; /** Runtime only (never persisted) - the live group session, if any. */ livePartyId: string | null; } /** The persisted shape of a StaticParty - everything but runtime state. */ export type PersistedStaticParty = Omit; /** One item of a static watch party, as the client sees it. */ export interface StaticPartyItemDto extends QueueItem { groupState: 'watched' | 'next' | 'upcoming'; /** Whether the viewer has watched it (in-app or Emby's Played flag). */ viewerWatched: boolean; } /** GET /api/static-parties list entry. */ export interface StaticPartySummaryDto { id: string; name: string; sourceType: 'series' | 'playlist'; imageUrl: string; itemCount: number; groupWatchedCount: number; nextItemName: string | null; memberCount: number; youAreMember: boolean; youAreCreator: boolean; missedCount: number; livePartyId: string | null; } /** GET /api/static-parties/:id - the summary plus every item and member. */ export interface StaticPartyDetailDto extends StaticPartySummaryDto { items: StaticPartyItemDto[]; missedItemIds: string[]; members: Array<{ userId: string; userName: string; watchedCount: number; isCreator: boolean }>; } 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; 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; }