'use strict'; import { EmbyClient } from '../emby/EmbyClient'; import { Logger } from '../Logger'; /** * Reports a party member's own playback to Emby's real Sessions API * (`POST /Sessions/Playing`, `/Sessions/Playing/Progress`, * `/Sessions/Playing/Stopped`) - the piece of session lifecycle * docs/architecture.md's "Sync watch-party progress back to Emby" * section deliberately deferred at the time ("wiring clean start/stop * into every transition... is real complexity for a secondary payoff"). * Asked for directly since: without this, this app's own streams never * showed up in Emby's admin "now playing" dashboard the way a real web/ * mobile/TV client's would - only the resume-position/watched-history * UserData (PlaystateService) was ever visible anywhere, and only by * opening the item itself, not from the dashboard's live session list. * * One real Emby session per PARTY MEMBER, not one per party - a * faithful copy of how a real client behaves (every device/account gets * its own), and the actual point of the request ("let admin see who's * watching what") would be lost by collapsing every member onto one * shared entry. Every call needs the member's own `embyToken` (the same * source every other per-member Emby write in this app already uses - * see `PartyMember.embyToken`/`PlaystateService`) - Emby's Sessions API * is inherently per-account, there's no equivalent of "the party's" own * session to report against instead. * * `MediaSourceId`/`PlaySessionId` (see `CatalogService * .negotiatePlaybackSession`) are required in practice, not just * present in the schema - confirmed by reading the real server's own * `/openapi.json` rather than assumed, after this exact neighboring * area (`PlaystateService`'s own history) already got bitten twice by * an Emby endpoint accepting a request shape that looked complete but * wasn't. `index.ts`'s periodic sweep is what actually drives the * start/progress/stop transitions - see its own comment for why that * one place could cover this instead of scattering hooks across every * queue-advance/track-skip call site the way `PlaystateService * .markPlayed` needed to. * * Best-effort throughout, exactly like `PlaystateService`: any failure * is caught and logged here, never thrown - none of this may ever * disrupt real playback over an admin-dashboard nicety. */ class EmbySessionServiceClass { /** Called once per member when they start watching/listening to * `itemId` - a fresh join, or the party moving to a new item while * they're already a member (see index.ts's sweep, which detects that * by comparing against `PartyMember.embySession.itemId`). */ async start(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise { await this.report('/Sessions/Playing', embyToken, { ItemId: itemId, MediaSourceId: mediaSourceId, PlaySessionId: playSessionId, PositionTicks: Math.round(positionTicks), IsPaused: isPaused, PlayMethod: 'Transcode', CanSeek: true }); } /** Called periodically (~20s, alongside `PlaystateService * .updatePosition` - see index.ts's sweep) for every member with a * live session already open for their current item. */ async progress(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise { await this.report('/Sessions/Playing/Progress', embyToken, { ItemId: itemId, MediaSourceId: mediaSourceId, PlaySessionId: playSessionId, PositionTicks: Math.round(positionTicks), IsPaused: isPaused, PlayMethod: 'Transcode' }); } /** Called once per member when they stop watching/listening to * `itemId` - leaving the party (`PartyService.leave`), the item * changing (index.ts's sweep), or the party being destroyed for * inactivity (`PartyService.removeIdleParties`). */ async stop(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number): Promise { await this.report('/Sessions/Playing/Stopped', embyToken, { ItemId: itemId, MediaSourceId: mediaSourceId, PlaySessionId: playSessionId, PositionTicks: Math.round(positionTicks) }); } /** Every call logs its own outgoing request + outcome - see * Logger.playstate's own comment for why this needs to be enough on * its own, greppable, to answer "is this actually reaching Emby, and * with what" for this service too, not just PlaystateService. */ private async report(path: string, embyToken: string, body: Record): Promise { Logger.playstate(`-> POST ${path} itemId=${body.ItemId}`); try { const res = await EmbyClient.fetch(path, embyToken, { method: 'POST', body }); Logger.playstate(`<- ${res.status} ${res.ok ? 'ok' : 'FAILED'} ${path} itemId=${body.ItemId}`); } catch (err) { Logger.playstate(`<- threw ${path} itemId=${body.ItemId}: ${(err as Error).message}`); } } } export const EmbySessionService = new EmbySessionServiceClass();