A watch party built from a whole series or video playlist that remembers the group's progress and each member's, so someone coming back or joining late sees the items they missed and can catch up (solo, as a catch-up watch party, or by marking items watched). - StaticPartyService + StaticPartyStore: first on-disk state (JSON file under DATA_DIR, atomic debounced writes, flushed on SIGTERM) - each session is an ordinary Party linked via party.staticLink; group position is saved by the 20s sweep and resumed on the next session - PartyService.completeCurrentItem centralizes "item done" (Emby Played + static progress); new host-only "itemEnded" socket event covers the last queued item, which was never marked Played before - missed = group-completed minus watched in-app minus Emby Played - /static/:id view, create buttons on series/playlist pages, home section - Docker volume for /data, spec/static-watch-party.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
592 lines
24 KiB
TypeScript
592 lines
24 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;
|
|
/** 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 <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;
|
|
/** 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 <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;
|
|
/** 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<string, StaticPartyMember>;
|
|
/** 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<StaticParty, 'livePartyId'>;
|
|
|
|
/** 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<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;
|
|
}
|