import { requestErrorContext } from './request-error.js' import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js' import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js' import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { DEFAULT_SERVER, type GameMaterial, type OnlineIntent, type OnlineState, } from '../src/types/online.js' import type { Intent, PlanStatus, Report, ResultsSnapshot, ScreenPlan, } from '../src/types/screen.js' import type { Controller } from './controller.js' import { PackageStore, packageKey } from './package-store.js' import { type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js' import { parseResults } from './results.js' import { parsePlan } from './screen-plan.js' import { applyRemote, integer, object, parseRemote, send, text, type RemoteState, } from './remote-state.js' export interface SecretStorage { available(): boolean encrypt(value: string): Buffer decrypt(value: Buffer): string } interface ScreenSession { id: string token: string gameId: number } interface Credentials { server: string access: string refresh: string screen: ScreenSession | null } function serverURL(value: unknown): string { const url = new URL(text(value)) if ( !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '') ) throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров') return url.origin === DEFAULT_SERVER ? 'https://evening-detective-api.crabs-games.art' : url.origin } function token(value: unknown): string { const s = text(value) if (!s || s.length > 16384 || /[\r\n]/.test(s)) throw new Error('Сервер не вернул действующие токены') return s } export class Online { readonly state: OnlineState = { server: DEFAULT_SERVER, currentGameId: null, materials: [], authenticated: false, remembered: false, busy: false, busyMessage: '', canCancel: false, games: [], playlists: [], screenId: null, soundEnabled: null, connection: 'disabled', error: '', notice: '', progress: null, preparation: { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '' }, packagesRevision: 0, } private credentials: Credentials | null = null private screen: ScreenSession | null = null private generation = 0 private refreshPromise: Promise | null = null private writes: Promise = Promise.resolve() private cancellation: AbortController | null = null private downloadKey: string | null = null private previous: RemoteState | null = null private received: RemoteState | null = null private mainApplied: RemoteState | null = null private musicApplied: RemoteState | null = null private materialAbort = new AbortController() private materialRefresh: Promise | null = null private materialWork: Promise | null = null private lastMaterials = -Infinity private mainWork: Promise | null = null private musicWork: Promise | null = null private mainSession = '' private musicSession = '' private receivedSignature = '' private localCommandVersion = 0 private materialSignature = '' private async description(gameId: number): Promise { if (!this.credentials) throw new ApiError(401) return prepareScreenPackage( { server: this.credentials.server, gameId, resources: this.received?.gameId === gameId ? this.received.resources : undefined, token: this.credentials.access, signal: this.materialAbort.signal, onRequestFailure: this.onRequestFailure, }, (url, init) => { const parsed = new URL(String(url)) if (parsed.origin !== this.credentials?.server) throw new Error('Недопустимый сервер API') return this.authorized(parsed.pathname, init) }, ) } private async refreshMaterials() { if (this.materialRefresh) return this.materialRefresh const pkg = this.selectedGame() if (!pkg || !this.planStatus().editable) return const generation = this.generation const signature = this.receivedSignature this.lastMaterials = Date.now() const work = (async () => { try { const description = await this.description(pkg.package.game.id) if (generation !== this.generation) return const current = this.selectedGame() if ( current?.package.metadataVersion !== description.metadataVersion || !this.packages.description(pkg.key) ) { const local = await this.packages.remember(description) if (generation !== this.generation) return this.controller.selectPackage(local, true) await this.checkMaterials(local.package) this.state.packagesRevision++ } this.materialSignature = signature this.prefetch() } catch (error) { if (generation === this.generation) this.state.preparation.error = error instanceof Error ? error.message : 'Описание игры недоступно' } })() this.materialRefresh = work try { await work } finally { if (this.materialRefresh === work) this.materialRefresh = null } } private wanted(next: RemoteState, music: boolean): string[] { const pkg = this.selectedGame()?.package if (!pkg) return [] if (music) { const m = next.music const track = pkg.playlists .find((p) => p.id === m?.playlistId) ?.tracks.find((t) => t.id === m?.trackId) if (m?.playlistId && m.trackId && !track) throw new Error('Описание выбранного трека ещё не получено') const expected = m?.filenames[m.trackIds.indexOf(m.trackId ?? 0)] if ( track && expected && pkg.assets.find((a) => a.id === track.assetId)?.url !== resolveMediaURL(pkg.server, expected) ) throw new Error('Ожидание актуального описания трека') return track ? [track.assetId] : [] } for (const [id, expected] of [ [ next.mode === 1 ? pkg.media.cover : pkg.media.background, next.mode === 1 ? next.resources.cover : next.resources.background, ], [ next.mode === 2 ? pkg.media.intro : next.mode === 4 ? pkg.media.conclusion : null, next.mode === 2 ? next.resources.intro : next.mode === 4 ? next.resources.conclusion : '', ], ]) { if ( expected && pkg.assets.find((a) => a.id === id)?.url !== resolveMediaURL(pkg.server, expected) ) throw new Error('Ожидание актуального описания экрана') } return [ ...new Set( [ next.mode === 1 ? pkg.media.cover : pkg.media.background, next.mode === 2 ? pkg.media.intro : next.mode === 4 ? pkg.media.conclusion : null, ].filter((id): id is string => !!id), ), ] } private prefetch() { if (this.materialWork) return const pkg = this.selectedGame() if (!pkg) return const generation = this.generation const signal = this.materialAbort.signal const description = pkg.package let wanted: string[] = [] if (this.received) { try { wanted = [...this.wanted(this.received, false), ...this.wanted(this.received, true)] } catch { /* Новый плейлист будет включён после обновления описания. */ } } const ids = [...new Set([...wanted, ...description.assets.map((a) => a.id)])] this.state.preparation = { active: true, completed: 0, total: ids.length, downloadedBytes: 0, error: '', } const work = (async () => { for (const id of ids) { if (generation !== this.generation || signal.aborted) return try { await this.packages.ensureAsset(description, id, signal, (bytes) => { if (generation === this.generation) this.state.preparation.downloadedBytes = bytes }) if (generation === this.generation) { this.controller.markAssetReady(id) this.state.preparation.completed++ } } catch (error) { if (generation !== this.generation || signal.aborted) return this.state.preparation.error = error instanceof Error ? error.message : 'Файл недоступен' } } })() this.materialWork = work void work.finally(() => { if (this.materialWork === work) { this.materialWork = null if (generation === this.generation) this.state.preparation.active = false } }) } private mainSourceMatches(next: RemoteState) { const kind = next.mode === 2 ? 'audio' : next.mode === 4 ? 'video' : null if (!kind) return true return ( this.controller.state.channels.main.source?.url === this.controller.state.catalog.media.find((source) => source.kind === kind)?.url ) } private musicSourceMatches(next: RemoteState) { try { const id = this.wanted(next, true)[0] const channel = this.controller.state.channels.music return ( next.music?.playlistId === channel.playlistId && (id ? `screen-app://app/packages/${this.selectedGame()?.key}/${id}` : undefined) === channel.source?.url ) } catch { return false } } private async applyReceived(music: boolean, duringCommand = false) { let next = this.received const pkg = this.selectedGame() if (!next || !pkg || !this.controller.state.outputOpen || (this.planBusy && !duringCommand)) return const session = this.controller.state.session const generation = this.generation const version = this.planVersion const old = music ? this.musicApplied : this.mainApplied const sameSession = (music ? this.musicSession : this.mainSession) === session if ( old && sameSession && (music ? JSON.stringify(old.music, (_key, value) => typeof value === 'bigint' ? String(value) : value, ) === JSON.stringify(next.music, (_key, value) => typeof value === 'bigint' ? String(value) : value, ) : old.version === next.version && this.mainSourceMatches(next) && JSON.stringify(old.resources) === JSON.stringify(next.resources)) ) return try { if (this.materialSignature !== this.receivedSignature) await this.refreshMaterials() const description = this.selectedGame()?.package if (!description || generation !== this.generation) return if (!music && next.command === 0) await this.refreshPlan() const ids = this.wanted(next, music) await Promise.all( ids.map((id) => this.packages.ensureAsset(description, id, this.materialAbort.signal)), ) if ( generation !== this.generation || session !== this.controller.state.session || version !== this.planVersion || !this.controller.state.outputOpen ) return for (const id of ids) this.controller.markAssetReady(id) const latest = this.received if ( !latest || (music ? latest.music?.version !== next.music?.version || JSON.stringify(latest.music?.filenames) !== JSON.stringify(next.music?.filenames) : latest.version !== next.version || JSON.stringify(latest.resources) !== JSON.stringify(next.resources)) ) return next = latest const base = old && sameSession ? { ...old } : { ...next, version: -1n, mode: this.controller.state.scene.mode, music: null, } if ( music && old && old.music?.trackId === next.music?.trackId && !this.musicSourceMatches(next) ) base.music = null if (!music && old && JSON.stringify(old.resources) !== JSON.stringify(next.resources)) base.version = -1n if (music) { applyRemote(this.controller, { ...base, version: next.version, sound: next.sound }, next) this.musicApplied = next this.musicSession = session } else { const key = this.controller.state.catalog.key const url = (id: string | null) => (id ? `screen-app://app/packages/${key}/${id}` : null) this.controller.setImages(url(description.media.cover), url(description.media.background)) applyRemote(this.controller, { ...base, music: next.music }, { ...next, music: next.music }) this.mainApplied = next this.mainSession = session if (next.command === 0) this.controller.markStep(this.plan.currentStep) } } catch (error) { if (generation === this.generation && !this.materialAbort.signal.aborted) this.state.preparation.error = error instanceof Error ? error.message : 'Материал не подготовлен' } } private synchronize() { if (!this.mainWork) { const work = this.applyReceived(false) this.mainWork = work void work.finally(() => { if (this.mainWork === work) this.mainWork = null }) } if (!this.musicWork) { const work = this.applyReceived(true) this.musicWork = work void work.finally(() => { if (this.musicWork === work) this.musicWork = null }) } return Promise.all([this.mainWork, this.musicWork]) } private pollBusy = false private pollSession = '' private lastSound: boolean | null = null private lastResults = -Infinity private resultsBusy = false private advanceBusy = false private plan: ScreenPlan = { steps: [], currentStep: 0 } private planBusy = 0 private planCommands: Promise = Promise.resolve(true) private planPolling: Promise | null = null private lastPlan = -Infinity private planVersion = 0 private planWrites: Promise = Promise.resolve() private cachedPlan = '' private planNotice = '' planStatus(): PlanStatus { const pkg = this.selectedGame() return { key: this.controller.state.catalog.key, editable: !!pkg && !!this.credentials && pkg.package.server === this.credentials.server, notice: this.planNotice, } } private selectedGame() { return this.packages.list().find((p) => p.key === this.controller.state.catalog.key) } private planGame() { const pkg = this.selectedGame() if (!pkg || !this.credentials || pkg.package.server !== this.credentials.server) throw new Error('Войдите на сервер выбранной подготовленной игры') return pkg } private async acceptPlan(plan: ScreenPlan, key: string, generation: number, version: number) { if ( generation !== this.generation || key !== this.controller.state.catalog.key || version !== this.planVersion ) return const serialized = JSON.stringify(plan) const hasChanged = this.cachedPlan !== serialized this.plan = plan this.controller.setPlan(plan.steps) this.planNotice = '' if (!hasChanged) return try { const write = this.planWrites.then(async () => { if (generation === this.generation && version === this.planVersion) await this.atomic(`plan-${key}.json`, serialized) if (generation === this.generation && version === this.planVersion) this.cachedPlan = serialized }) this.planWrites = write.catch(() => {}) await write } catch { if (generation === this.generation && version === this.planVersion) this.planNotice = 'План получен с сервера, но локальную копию сохранить не удалось' } } async refreshPlan() { if (this.planPolling) return this.planPolling const task = this.loadPlan() this.planPolling = task try { await task } finally { this.planPolling = null } } private async loadPlan() { if (this.planBusy || !this.planStatus().editable) return const pkg = this.planGame() const generation = this.generation const version = this.planVersion this.lastPlan = Date.now() try { const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({ gameID: pkg.package.game.id, }) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) } catch (error) { if (generation === this.generation && version === this.planVersion) this.planNotice = `${error instanceof Error ? error.message : 'План недоступен'}. Используется локальная копия` } } private async savePlan(data: Record) { if (this.planBusy) throw new Error('Подождите завершения серверной команды') if (!Array.isArray(data.steps)) throw new Error('Некорректные шаги плана') const pkg = this.planGame() if (data.key !== pkg.key) throw new Error('Выбранная игра изменилась') const plan = parsePlan({ steps: data.steps, currentStep: 0 }) plan.currentStep = Math.min(this.plan.currentStep, Math.max(0, plan.steps.length - 1)) const generation = this.generation const version = ++this.planVersion this.planBusy++ try { const result = await this.client().SaveScreenPlan({ gameID: pkg.package.game.id, ...apiPlan(plan), }) if (generation !== this.generation) throw new SessionChangedError() await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) } finally { this.planBusy-- } } private localAssets(intent: Intent, pkg: ScreenPackage): string[] { if ('channel' in intent && intent.channel === 'music') { const channel = this.controller.state.channels.music const playlist = pkg.playlists.find( (p) => p.id === (intent.action === 'playlist' ? intent.playlistId : channel.playlistId), ) let index = intent.action === 'track' ? intent.trackIndex : intent.action === 'playlist' ? 0 : channel.trackIndex if (intent.action === 'next' && playlist) index = (index + 1) % Math.max(1, playlist.tracks.length) const track = playlist?.tracks[index] return track ? [track.assetId] : [] } let mode = this.controller.state.scene.mode if (intent.action === 'mode') mode = intent.mode if (intent.action === 'step') mode = this.controller.state.catalog.steps[intent.index]?.mode ?? mode if (intent.action === 'text') mode = 8 if (intent.action === 'timer-set') mode = 7 if (intent.action === 'select') mode = intent.sourceId === 'intro' ? 2 : 4 return [ mode === 1 ? pkg.media.cover : pkg.media.background, mode === 2 ? pkg.media.intro : mode === 4 ? pkg.media.conclusion : null, ].filter((id): id is string => !!id) } async planCommand(intent: Intent) { if (this.busy) throw new Error('Подождите завершения текущего действия') const pkg = this.selectedGame() const localVersion = ++this.localCommandVersion const localGeneration = this.generation const localSession = this.controller.state.session if (pkg) { const ids = this.localAssets(intent, pkg.package) const hasError = this.controller.state.scene.status === 'error' || ('channel' in intent && this.controller.state.channels[intent.channel].status === 'error') if (hasError || ids.some((id) => !this.packages.canRead(pkg.key, id))) { await Promise.all( ids.map((id) => this.packages.ensureAsset(pkg.package, id, this.materialAbort.signal)), ) if ( localVersion !== this.localCommandVersion || localGeneration !== this.generation || localSession !== this.controller.state.session ) return } } // Готовые локальные ресурсы включаются без ожидания ответа сервера. send(this.controller, intent) const localState = structuredClone(this.controller.state) const sequence = this.controller.state.sequence const version = ++this.planVersion if (!pkg || !this.planStatus().editable) { this.planNotice = 'Переход выполнен только локально; войдите на сервер игры' return } const generation = this.generation const session = this.controller.state.session const hasPendingCommand = this.planBusy > 0 const previousCommand = this.planCommands let complete!: (available: boolean) => void this.planCommands = new Promise((resolve) => { complete = resolve }) this.planBusy++ let isAvailable = true let expectedCommand = 0 try { // В сеть уходит только последняя команда; после обрыва ожидающие действия не повторяются. const wasAvailable = await previousCommand isAvailable = !hasPendingCommand || wasAvailable if (generation !== this.generation || session !== this.controller.state.session) return if ( version !== this.planVersion && ['step', 'mode', 'text', 'select', 'timer-set', 'playlist', 'track', 'next'].includes( intent.action, ) ) return if (hasPendingCommand && !wasAvailable) { isAvailable = false this.previous = null this.planNotice = 'Команда выполнена локально; сервер недоступен' return } if (intent.action === 'step') { const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({ gameID: pkg.package.game.id, currentStep: intent.index, }) const confirmed = parsePlan(result.plan) const shown = this.controller.state.catalog.steps[intent.index] await this.acceptPlan(confirmed, pkg.key, generation, version) if ( generation !== this.generation || version !== this.planVersion || session !== this.controller.state.session ) return if ( this.controller.state.sequence === sequence && JSON.stringify(shown) !== JSON.stringify(confirmed.steps[intent.index]) ) { if (confirmed.steps[intent.index]) send(this.controller, intent) } } else if ('channel' in intent && intent.channel === 'music') { const c = localState.channels.music await this.client(true, AbortSignal.timeout(5000)).SetGameMusic({ gameID: pkg.package.game.id, playlistID: c.playlistId ?? 0, trackIndex: c.trackIndex, positionMs: Math.max(0, Math.round(c.position * 1000)), volume: Math.round(c.volume), loop: c.loop, playback: c.desired === 'playing' ? 'MUSIC_PLAYBACK_PLAYING' : c.desired === 'paused' ? 'MUSIC_PLAYBACK_PAUSED' : 'MUSIC_PLAYBACK_STOPPED', }) } else if (intent.action === 'mode' || intent.action === 'select') { await this.client(true, AbortSignal.timeout(5000)).SetScreenMode({ gameID: pkg.package.game.id, mode: apiScreenMode(localState.scene.mode), }) } else if (intent.action === 'text') { const current = parsePlan( (await this.client().GetScreenPlan({ gameID: pkg.package.game.id })).plan, ) if ( generation !== this.generation || session !== this.controller.state.session || version !== this.planVersion ) return let index = current.steps.findIndex((step) => step.mode === 8 && step.text === intent.text) if (index < 0) { index = current.steps.length current.steps.push({ mode: 8, text: intent.text, timerLabel: '', timerDurationSeconds: 0, }) await this.client().SaveScreenPlan({ gameID: pkg.package.game.id, ...apiPlan(current) }) } if ( generation !== this.generation || session !== this.controller.state.session || version !== this.planVersion ) return const result = await this.client().SetScreenPlanStep({ gameID: pkg.package.game.id, currentStep: index, }) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) if ( generation !== this.generation || session !== this.controller.state.session || version !== this.planVersion ) return this.controller.markStep(index) } else if (intent.action === 'timer-set') { await this.client(true, AbortSignal.timeout(5000)).SetScreenTimer({ gameID: pkg.package.game.id, label: intent.label, durationSeconds: intent.durationSeconds, }) } else { const index = this.controller.state.scene.stepIndex if (index !== null && index !== this.plan.currentStep) { const result = await this.client(true, AbortSignal.timeout(5000)).SetScreenPlanStep({ gameID: pkg.package.game.id, currentStep: index, }) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) if ( generation !== this.generation || version !== this.planVersion || session !== this.controller.state.session ) return } const commands = { play: 1, pause: 2, resume: 3, stop: 4, replay: 5, seek: 6, volume: 7, 'timer-start': 8, 'timer-pause': 9, 'timer-resume': 10, 'timer-reset': 11, } if (!(intent.action in commands)) throw new Error('Некорректная команда плана') expectedCommand = commands[intent.action as keyof typeof commands] await this.client(true, AbortSignal.timeout(5000)).SendScreenCommand({ gameID: pkg.package.game.id, command: apiScreenCommand(expectedCommand), positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0, volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume, }) } if ( generation !== this.generation || session !== this.controller.state.session || version !== this.planVersion ) return this.planNotice = '' // Ответ следующего опроса становится базой: собственная команда не применяется дважды. if (this.screen) { const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({ screenID: this.screen.id, accessToken: this.screen.token, }) if (generation !== this.generation || session !== this.controller.state.session) return const remote = parseRemote(result.state) if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась') if (version !== this.planVersion) return const scene = this.controller.state.scene const isMusicCommand = 'channel' in intent && intent.channel === 'music' let needsMainSync = !isMusicCommand && (remote.command !== expectedCommand || remote.mode !== scene.mode || (remote.mode === 8 && remote.text !== scene.text) || (remote.mode === 7 && (remote.timer.label !== this.controller.state.timer.label || remote.timer.durationMs !== this.controller.state.timer.durationMs)) || (intent.action === 'seek' && remote.position !== intent.position) || (intent.action === 'volume' && remote.volume !== intent.volume) || !this.mainSourceMatches(remote)) try { this.wanted(remote, false) } catch { needsMainSync = !isMusicCommand } if (needsMainSync && remote.command === 0) { const result = await this.client(true, AbortSignal.timeout(5000)).GetScreenPlan({ gameID: pkg.package.game.id, }) await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version) if ( generation !== this.generation || version !== this.planVersion || session !== this.controller.state.session ) return } if (!this.previous || this.previous.sound !== remote.sound) send(this.controller, { action: 'mute', enabled: !remote.sound }) this.lastSound = remote.sound this.state.soundEnabled = remote.sound this.previous = remote this.received = remote this.receivedSignature = JSON.stringify([ remote.resources, remote.music?.playlistId, remote.music?.trackIds, remote.music?.filenames, ]) if (!('channel' in intent && intent.channel === 'music')) { if (needsMainSync) { this.mainApplied = null await this.applyReceived(false, true) } else { this.mainApplied = remote this.mainSession = session } } if ('channel' in intent && intent.channel === 'music') { const c = localState.channels.music const expectedTrack = localState.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[c.trackIndex] ?.id ?? null const m = remote.music if ( m && m.playlistId === c.playlistId && this.musicSourceMatches(remote) && m.trackId === expectedTrack && m.volume === Math.round(c.volume) && m.loop === c.loop && m.playback === (c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1) && Math.abs(m.position - c.position) < 0.01 ) { this.musicApplied = remote this.musicSession = session } else { this.musicApplied = null await this.applyReceived(true, true) } } } } catch (error) { isAvailable = !(error instanceof NetworkError) if ( generation !== this.generation || session !== this.controller.state.session || version !== this.planVersion ) return this.previous = null this.planNotice = 'Команда выполнена локально; сервер не подтвердил обновление' if (!(error instanceof NetworkError)) throw error } finally { this.planBusy-- complete(isAvailable) } } constructor( private directory: string, private packages: PackageStore, private controller: Controller, private secrets: SecretStorage, private fetchAPI: typeof fetch = fetch, private onRequestFailure: NonNullable< import('./screen-package.js').PackageRequest['onRequestFailure'] > = () => {}, private chooseTrack: () => Promise<{ filename: string; data: Buffer } | null> = async () => null, ) { controller.onMusicEnded = (report) => { if (!this.screen) return false void this.musicEnded(report) return true } } get blocksOutput() { return this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key } get busy() { return this.state.busy } snapshot(): OnlineState { const pkg = this.selectedGame()?.package const names = new Map() const usages = new Map() const add = ( id: string | null, name: string, order: number, groupId = 'game', groupName = 'Игра', ) => { if (!id) return names.set(id, [ ...(names.get(id) ?? []), groupId === 'game' ? name : `${groupName} · ${name}`, ]) usages.set(id, [...(usages.get(id) ?? []), { groupId, groupName, name, order }]) } if (pkg) { add(pkg.media.cover, 'Обложка', 0) add(pkg.media.background, 'Фон', 1) add(pkg.media.intro, 'Вступление', 2) add(pkg.media.conclusion, 'Заключение', 3) for (const playlist of pkg.playlists) for (const track of playlist.tracks) add(track.assetId, track.name, track.position, `playlist-${playlist.id}`, playlist.name) } const key = this.controller.state.catalog.key return structuredClone({ ...this.state, currentGameId: pkg?.server === this.state.server ? pkg.game.id : null, materials: pkg && key ? pkg.assets.map((asset) => ({ id: asset.id, name: names.get(asset.id)?.join(', ') ?? 'Материал игры', usages: usages.get(asset.id) ?? [ { groupId: 'game', groupName: 'Игра', name: 'Материал игры', order: 4 }, ], ...this.packages.assetStatus(key, asset.id), ...(this.downloadKey === key && this.state.progress?.assetId === asset.id ? { status: 'loading' as const } : {}), })) : [], }) } private async checkMaterials(pkg: ScreenPackage) { const key = packageKey(pkg.server, pkg.game.id) for (const asset of pkg.assets) await this.packages.checkAsset(key, asset.id) } private async atomic(name: string, data: string | Buffer) { await mkdir(this.directory, { recursive: true, mode: 0o700 }) const path = join(this.directory, name) await writeFile(`${path}.tmp`, data, { mode: 0o600 }) await rename(`${path}.tmp`, path) } private persistSecrets() { const data = this.credentials ? { ...this.credentials, screen: this.screen } : null this.state.remembered = !!data && this.secrets.available() let encrypted: Buffer | null = null try { if (this.state.remembered) encrypted = this.secrets.encrypt(JSON.stringify(data)) } catch { this.state.remembered = false this.state.notice = 'Не удалось сохранить вход средствами ОС. Сессия действует до закрытия приложения' } const write = this.writes.then(async () => { if (encrypted) await this.atomic('credentials.bin', encrypted) else await rm(join(this.directory, 'credentials.bin'), { force: true }) }) this.writes = write.catch(() => {}) return write } async initialize() { try { const config = object( JSON.parse(await readFile(join(this.directory, 'selection.json'), 'utf8')), ) if (config.server) this.state.server = serverURL(config.server) if (typeof config.key === 'string' && /^[a-f0-9]{64}$/.test(config.key)) await this.select(config.key, false) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.state.error = 'Не удалось восстановить выбранные материалы; выберите готовый пакет' } if (!this.secrets.available()) return try { const raw = object( JSON.parse(this.secrets.decrypt(await readFile(join(this.directory, 'credentials.bin')))), ) const server = serverURL(raw.server) const c: Credentials = { server, access: token(raw.access), refresh: token(raw.refresh), screen: null, } if (raw.screen) { const s = object(raw.screen) c.screen = { id: String(integer(s.id, 1)), token: token(s.token), gameId: integer(s.gameId, 1), } } this.credentials = c this.state.server = server this.state.authenticated = true this.state.remembered = true const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key) if (c.screen && pkg?.package.server === server && pkg.package.game.id === c.screen.gameId) { this.screen = c.screen this.state.screenId = c.screen.id this.state.connection = 'waiting' } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.state.error = 'Сохранённый вход недоступен. Войдите снова; локальные материалы доступны' } } private async saveSelection() { await this.atomic( 'selection.json', JSON.stringify({ server: this.state.server, key: this.controller.state.catalog.key }), ) } async select(key: string | null, persist = true) { if (this.busy) throw new Error('Подождите завершения подготовки материалов') let pkg try { pkg = key === null ? null : (this.packages.description(key) ?? (await this.packages.verify(key))) } catch (error) { if (key === this.controller.state.catalog.key) { this.detach() this.controller.selectPackage(null) await this.saveSelection() await this.persistSecrets() this.state.packagesRevision++ } throw error } this.detach() this.controller.selectPackage(pkg) if (pkg) await this.checkMaterials(pkg.package) this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 } this.planNotice = '' this.lastPlan = -Infinity this.cachedPlan = '' if (key) { try { this.plan = parsePlan( JSON.parse(await readFile(join(this.directory, `plan-${key}.json`), 'utf8')), ) this.controller.setPlan(this.plan.steps) this.cachedPlan = JSON.stringify(this.plan) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.planNotice = 'Локальная копия плана повреждена; используется план пакета' } } this.lastResults = -Infinity if (key) await this.restoreResults(key) if (persist) { await this.saveSelection() await this.persistSecrets() } } private detach() { this.materialAbort.abort() this.materialAbort = new AbortController() this.received = null this.mainApplied = null this.musicApplied = null this.receivedSignature = '' this.materialSignature = '' this.lastMaterials = -Infinity this.state.preparation = { active: false, completed: 0, total: 0, downloadedBytes: 0, error: '', } this.generation++ this.planVersion++ this.screen = null this.previous = null this.lastSound = null this.state.soundEnabled = null this.state.screenId = null this.state.connection = 'disabled' } private async raw(server: string, path: string, init: RequestInit = {}): Promise { const signal = AbortSignal.any([ AbortSignal.timeout( init.method === 'POST' && /^\/api\/playlists\/\d+\/tracks$/.test(path) ? 120_000 : 30_000, ), ...(init.signal ? [init.signal] : []), ]) try { return await this.fetchAPI(`${server}${path}`, { ...init, redirect: 'error', credentials: 'omit', signal, }) } catch (error) { throw networkFailure(error, signal) } } private async json(response: Response): Promise> { if (!response.ok) throw new ApiError(response.status) let value: unknown try { value = await response.json() } catch { throw new Error('Сервер вернул некорректный JSON') } return object(value, 'response') } private async refresh(failed: string, generation: number) { if (this.credentials?.access !== failed) return if (this.refreshPromise) return this.refreshPromise const c = this.credentials! this.refreshPromise = (async () => { let result try { result = await this.client(false, undefined, c.server).Refresh({ refreshToken: c.refresh }) } catch (error) { if (generation !== this.generation) throw new SessionChangedError() if (error instanceof ApiError && error.status === 401) { this.credentials = null this.state.authenticated = false await this.persistSecrets() } throw error } if (generation !== this.generation) throw new SessionChangedError() this.credentials = { ...c, access: token(result.accessToken), refresh: token(result.refreshToken), } await this.persistSecrets() })().finally(() => { this.refreshPromise = null }) return this.refreshPromise } private async authorized(path: string, init: RequestInit = {}) { const c = this.credentials const generation = this.generation if (!c) throw new ApiError(401) const request = (access: string) => { const headers = new Headers(init.headers) headers.set('Authorization', `Bearer ${access}`) return this.raw(c.server, path, { ...init, headers }) } let response = await request(c.access) if (generation !== this.generation) throw new SessionChangedError() if (response.status === 401) { await response.body?.cancel() await this.refresh(c.access, generation) if (generation !== this.generation || !this.credentials) throw new ApiError(401) response = await request(this.credentials.access) if (response.status === 401) { this.credentials = null this.state.authenticated = false await this.persistSecrets() } } return response } private client(authorized = true, signal?: AbortSignal, server = this.state.server) { return createEveningDetectiveServerClient(async (request, meta) => { const init: RequestInit = { method: request.method, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: request.body, signal, } const path = `/${request.path}` try { const result = await this.json( authorized ? await this.authorized(path, init) : await this.raw(server, path, init), ) // Проверяем JSON до выхода из транспорта, пока доступен контекст запроса. if (meta.method === 'GetScreenState') parseRemote(result.state) if (meta.method === 'AdvanceScreenMusic') parseRemote({ music: result.state }) if (['GetScreenPlan', 'SaveScreenPlan', 'SetScreenPlanStep'].includes(meta.method)) parsePlan(result.plan) return result } catch (error) { if ( error instanceof ApiError && error.status === 400 && ['DeletePlaylist', 'DeletePlaylistTrack'].includes(meta.method) ) error.message = 'Плейлист используется игрой или сценарием; удаление недоступно' throw requestErrorContext(error, meta.method, request.method, path) } }) } async act(input: unknown): Promise { const data = object(input) if (data.action === 'cancel') { this.cancellation?.abort() this.packages.cancel() return this.snapshot() } if (this.busy) throw new Error('Подождите завершения текущего действия') const isBackgroundRefresh = data.action === 'playlists' this.state.busy = true const messages: Record = { playlists: 'Загрузка списка плейлистов…', 'download-material': 'Загрузка файла игры…', 'playlist-create': 'Создание плейлиста…', 'playlist-rename': 'Сохранение названия плейлиста…', 'playlist-delete': 'Удаление плейлиста…', 'playlist-upload': 'Загрузка трека на сервер…', 'playlist-use': 'Подключение плейлиста к игре…', 'playlist-track-delete': 'Удаление трека…', 'playlist-reorder': 'Сохранение порядка треков…', 'plan-save': 'Сохранение плана экранов…', 'plan-refresh': 'Загрузка плана экранов…', login: 'Вход на сервер…', logout: 'Выход из аккаунта…', games: 'Загрузка списка игр…', connect: 'Подключение игры…', disconnect: 'Отключение игры…', download: 'Загрузка материалов игры…', check: 'Проверка материалов игры…', 'retry-materials': 'Подготовка материалов игры…', remove: 'Удаление локальных материалов…', sound: 'Изменение назначения звука…', results: 'Загрузка результатов игры…', cancel: 'Отмена загрузки…', } this.state.busyMessage = messages[data.action as OnlineIntent['action']] ?? 'Выполнение запроса…' if (!isBackgroundRefresh) { this.state.error = '' this.state.notice = '' } try { await this.perform(data) } catch (error) { const message = error instanceof Error ? error.message : 'Действие не выполнено' if (!isBackgroundRefresh || !this.state.error) this.state.error = message throw new Error(message) } finally { this.state.busy = false this.state.busyMessage = '' this.state.progress = null } return this.snapshot() } private async perform(data: Record) { const action = data.action as OnlineIntent['action'] switch (action) { case 'download-material': { const pkg = this.selectedGame() const assetId = text(data.assetId) if (!pkg || data.key !== pkg.key || !pkg.package.assets.some((a) => a.id === assetId)) throw new Error('Материал не принадлежит текущей игре') const generation = this.generation await this.packages.ensureAsset(pkg.package, assetId, this.materialAbort.signal) if (generation === this.generation && this.controller.state.catalog.key === pkg.key) this.controller.markAssetReady(assetId) return } case 'playlists': return this.loadPlaylists() case 'playlist-create': case 'playlist-rename': case 'playlist-delete': case 'playlist-upload': case 'playlist-track-delete': case 'playlist-reorder': case 'playlist-use': return this.editPlaylist(data) case 'plan-save': return this.savePlan(data) case 'plan-refresh': return this.refreshPlan() case 'login': { const server = serverURL(data.server) const email = text(data.email).trim() const password = text(data.password) if (!email.includes('@') || email.length > 320 || !password || password.length > 4096) throw new Error('Укажите email и пароль') this.detach() this.credentials = null this.state.authenticated = false this.state.games = [] this.state.playlists = [] await this.persistSecrets() this.state.server = server const result = await this.client(false, undefined, server).Login({ email, password }) this.credentials = { server, access: token(result.accessToken), refresh: token(result.refreshToken), screen: null, } try { const me = object((await this.client().GetMe({})).user) if ( !Array.isArray(me.roles) || !me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r))) ) throw new Error( 'Для управления экраном нужны права организатора, автора или администратора', ) } catch (error) { this.credentials = null await this.persistSecrets() throw error } this.state.authenticated = true await this.persistSecrets() await this.saveSelection() await this.loadGames() return } case 'logout': this.detach() this.credentials = null this.state.authenticated = false this.state.games = [] this.state.playlists = [] await this.persistSecrets() return case 'games': return this.loadGames() case 'download': case 'check': { const gameId = integer(data.gameId, 1) const key = packageKey(this.state.server, gameId) if ( action === 'download' && key === this.controller.state.catalog.key && this.controller.state.outputOpen ) throw new Error('Закройте показ перед обновлением выбранной игры') if (!this.credentials) throw new ApiError(401) this.downloadKey = action === 'download' ? key : null this.cancellation = new AbortController() this.state.canCancel = true try { const description = await prepareScreenPackage( { server: this.state.server, gameId, resources: this.received?.gameId === gameId ? this.received.resources : undefined, token: this.credentials.access, signal: this.cancellation.signal, onRequestFailure: this.onRequestFailure, }, (url, init) => { const parsed = new URL(String(url)) if (parsed.origin !== this.state.server) throw new Error('Недопустимый сервер API') return this.authorized(parsed.pathname, init) }, ) if (action === 'check') { const previous = this.packages.list().find((p) => p.key === key) this.state.notice = !previous ? 'Игра ещё не загружена' : previous.package.metadataVersion === description.metadataVersion ? 'Описание не изменилось. Для проверки замены файлов по прежним адресам загрузите материалы повторно' : 'На сервере есть изменения. Загрузите материалы при закрытом показе' return } const pkg = await this.packages.download(description, { signal: this.cancellation.signal, onProgress: (p) => { this.state.progress = p }, }) this.state.packagesRevision++ if (key === this.controller.state.catalog.key) { this.controller.selectPackage(pkg) this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0, } this.lastPlan = -Infinity this.cachedPlan = JSON.stringify(this.plan) await this.atomic(`plan-${key}.json`, this.cachedPlan) this.previous = null await this.restoreResults(key) } this.state.notice = 'Материалы проверены и готовы к показу' } finally { this.cancellation = null this.state.canCancel = false this.downloadKey = null } return } case 'remove': { const key = text(data.key) if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Некорректный пакет') if (key === this.controller.state.catalog.key) throw new Error('Сначала выберите другой пакет или тестовый режим') await this.packages.remove(key) await rm(join(this.directory, `results-${key}.json`), { force: true }) await rm(join(this.directory, `plan-${key}.json`), { force: true }) this.state.packagesRevision++ return } case 'retry-materials': this.lastMaterials = -Infinity await this.refreshMaterials() return case 'connect': { if (!this.credentials) throw new ApiError(401) let pkg = this.selectedGame() const gameId = data.gameId == null ? pkg?.package.game.id : integer(data.gameId, 1) if (!gameId) throw new Error('Выберите игру на сервере') if (this.screen) { this.state.busyMessage = 'Отключение предыдущего подключения…' try { await this.client().UnbindScreen({ screenID: this.screen.id }) } catch (error) { if (!(error instanceof ApiError && error.status === 404)) throw error } this.detach() await this.persistSecrets() } else this.detach() this.state.busyMessage = 'Подключение игры…' if (!pkg || pkg.package.game.id !== gameId || pkg.package.server !== this.state.server) { pkg = await this.packages.remember(await this.description(gameId)) this.controller.selectPackage(pkg) this.plan = { steps: this.controller.state.catalog.steps, currentStep: 0 } await this.checkMaterials(pkg.package) this.state.packagesRevision++ await this.saveSelection() } const result = await this.client(false).CreateScreenSession({}) const s: ScreenSession = { id: String(integer(result.screenID, 1)), token: token(result.accessToken), gameId, } await this.client().BindScreen({ screenID: s.id, accessToken: s.token, gameID: s.gameId, initializeAudio: true, }) send(this.controller, { action: 'mute', enabled: false }) send(this.controller, { action: 'volume', channel: 'main', volume: 100 }) send(this.controller, { action: 'volume', channel: 'music', volume: 100 }) this.state.soundEnabled = true this.screen = s this.state.screenId = s.id this.state.connection = 'waiting' await this.persistSecrets() return } case 'disconnect': this.detach() await this.persistSecrets() return case 'sound': { const generation = this.generation if (!this.screen) throw new Error('Сначала подключите управление') await this.client().SetScreenSound({ screenID: this.screen.id, soundEnabled: true }) if (generation === this.generation) this.state.soundEnabled = true return } case 'results': return this.refreshResults() default: throw new Error('Неизвестное действие') } } private async loadPlaylists() { const result = await this.client().GetPlaylists({}) if (!Array.isArray(result.playlists ?? [])) throw new Error('Некорректный список плейлистов') this.state.playlists = (result.playlists ?? []).map((value) => { const p = object(value) if (!Array.isArray(p.tracks ?? [])) throw new Error('Некорректный список треков') return { id: integer(p.ID, 1), name: text(p.name), tracks: ((p.tracks ?? []) as unknown[]).map((value) => { const t = object(value) return { id: integer(t.ID, 1), name: text(t.name) } }), } }) } private async editPlaylist(data: Record) { if (!this.credentials) throw new ApiError(401) const generation = this.generation const id = data.action === 'playlist-create' ? 0 : integer(data.playlistId, 1, 2147483647) const client = this.client() if (data.action === 'playlist-create' || data.action === 'playlist-rename') { const name = text(data.name).trim() if (!name || Buffer.byteLength(name) > 255) throw new Error('Имя плейлиста: от 1 до 255 байт') if (data.action === 'playlist-create') await client.CreatePlaylist({ name }) else await client.UpdatePlaylist({ ID: id, name }) } else if (data.action === 'playlist-delete') await client.DeletePlaylist({ ID: id }) else if (data.action === 'playlist-track-delete') await client.DeletePlaylistTrack({ playlistID: id, trackID: integer(data.trackId, 1, 2147483647), }) else if (data.action === 'playlist-reorder') { if (!Array.isArray(data.trackIds)) throw new Error('Некорректный порядок треков') const ids = data.trackIds.map((value) => integer(value, 1, 2147483647)) if (new Set(ids).size !== ids.length) throw new Error('Треки не должны повторяться') try { await client.ReorderPlaylistTracks({ playlistID: id, trackIDs: ids }) } catch (error) { if (error instanceof ApiError && error.status === 409) throw new Error( 'Состав треков изменился на сервере. Обновите список и повторите перестановку', ) throw error } } else if (data.action === 'playlist-upload') { const file = await this.chooseTrack() if (generation !== this.generation) throw new SessionChangedError() if (!file) return if (!/\.mp3$/i.test(file.filename) || !file.data.length || file.data.length > 64 * 1048576) throw new Error('Выберите MP3 размером до 64 МиБ') await client.UploadPlaylistTrack({ playlistID: id, name: file.filename, filename: file.filename, data: file.data.toString('base64'), }) } else if (data.action === 'playlist-use') { const pkg = this.planGame() await client.SetGameMusic({ gameID: pkg.package.game.id, playlistID: id, trackIndex: 0, positionMs: 0, volume: this.controller.state.channels.music.volume, loop: this.controller.state.channels.music.loop, playback: 'MUSIC_PLAYBACK_STOPPED', }) } await this.loadPlaylists() this.lastMaterials = -Infinity await this.refreshMaterials() await this.poll() } private async loadGames() { const result = await this.client().GetGames({}) if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр') this.state.games = ((result.games ?? []) as unknown[]).map((value) => { const g = object(value) return { id: integer(g.ID, 1), name: text(g.name), status: text(g.status ?? '') } }) } private async restoreResults(key: string) { try { this.controller.setResults( parseResults( JSON.parse(await readFile(join(this.directory, `results-${key}.json`), 'utf8')), key, ), ) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.state.error = 'Сохранённые результаты недоступны; обновите снимок' } } async refreshResults() { if (this.resultsBusy) return const s = this.screen const key = this.controller.state.catalog.key if (!s || !key) throw new Error('Подключите экран к выбранной игре для получения результатов') const generation = this.generation this.resultsBusy = true this.lastResults = Date.now() try { const result = await this.client(false).GetScreenResults({ screenID: s.id, accessToken: s.token, }) if (generation !== this.generation || key !== this.controller.state.catalog.key) return if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? [])) throw new Error('Некорректные результаты') const snapshot: ResultsSnapshot = parseResults( { packageKey: key, capturedAt: new Date().toISOString(), questions: result.questions ?? [], teams: ((result.teams ?? []) as unknown[]).map((value) => { const row = object(value) const team = object(row.team) if (!Array.isArray(row.answers ?? [])) throw new Error('Некорректные ответы') return { id: text(team.ID), name: text(team.name), place: integer(row.place, 1), totalScore: integer(row.totalScore ?? 0, -2147483648), actionsCount: integer(row.actionsCount ?? 0), answers: ((row.answers ?? []) as unknown[]).map((value) => { const a = object(value) return { questionCode: text(a.questionCode), score: integer(a.score ?? 0, -2147483648), } }), } }), }, key, ) await this.atomic(`results-${key}.json`, JSON.stringify(snapshot)) if (generation === this.generation && key === this.controller.state.catalog.key) this.controller.setResults(snapshot) } finally { this.resultsBusy = false } } async poll() { if (this.pollBusy) return const s = this.screen if (!s) return const generation = this.generation const version = this.planVersion this.pollBusy = true try { const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenState({ screenID: s.id, accessToken: s.token, }) if (generation !== this.generation || version !== this.planVersion) return let next = parseRemote(result.state) if (next.gameId !== s.gameId) { this.detach() await this.persistSecrets() this.state.error = 'Привязка экрана изменилась на сервере. Подключите нужную игру заново' return } if (this.received && next.version < this.received.version) next = { ...this.received, music: next.music, sound: next.sound } if (this.received?.music && next.music && next.music.version < this.received.music.version) next = { ...next, music: this.received.music } // Стабильная ссылка позволяет завершить подготовку, пока приходят одинаковые ответы. const equal = (a: unknown, b: unknown) => JSON.stringify(a, (_key, value) => (typeof value === 'bigint' ? String(value) : value)) === JSON.stringify(b, (_key, value) => (typeof value === 'bigint' ? String(value) : value)) if (!equal(this.received, next)) this.received = next this.previous = next this.receivedSignature = JSON.stringify([ next.resources, next.music?.playlistId, next.music?.trackIds, next.music?.filenames, ]) this.state.soundEnabled = next.sound if (this.lastSound !== next.sound) send(this.controller, { action: 'mute', enabled: !next.sound }) this.lastSound = next.sound this.state.connection = 'connected' this.state.error = '' if (!this.busy && Date.now() - this.lastPlan >= 5000) void this.refreshPlan() if ( this.materialSignature !== this.receivedSignature || Date.now() - this.lastMaterials >= 5000 ) void this.refreshMaterials() if (next.mode === 6 && Date.now() - this.lastResults >= 5000) void this.refreshResults().catch((error) => { if (generation === this.generation) this.state.error = error instanceof Error ? error.message : 'Результаты недоступны' }) } catch (error) { if (generation !== this.generation) return this.mainApplied = null this.musicApplied = null if (error instanceof ApiError && error.status === 401) { this.detach() this.state.connection = 'expired' await this.persistSecrets().catch(() => {}) } else this.state.connection = 'offline' this.state.error = error instanceof Error ? error.message : 'Сервер недоступен' } finally { this.pollBusy = false } if (generation === this.generation && this.state.connection === 'connected') await this.synchronize() } async musicEnded(report: Report) { const c = this.controller.state.channels.music if ( this.advanceBusy || report.session !== this.controller.state.session || report.operation !== c.operation || c.desired !== 'playing' ) return const generation = this.generation this.advanceBusy = true try { if (!this.screen || this.state.connection !== 'connected') throw new NetworkError('Нет связи') if (this.received && !this.musicSourceMatches(this.received)) { await this.synchronize() return } const result = await this.client(false, AbortSignal.timeout(5000)).AdvanceScreenMusic({ screenID: this.screen.id, accessToken: this.screen.token, }) if (generation !== this.generation || report.session !== this.controller.state.session) return const music = parseRemote({ music: result.state }).music if (this.received) { this.received = { ...this.received, music } await this.applyReceived(true) } } catch (error) { if (generation !== this.generation || report.session !== this.controller.state.session) return const pkg = this.selectedGame()?.package const playlist = pkg?.playlists.find((p) => p.id === c.playlistId) if (pkg && playlist) { const nextIndex = c.trackIndex + 1 const track = playlist.tracks[nextIndex < playlist.tracks.length ? nextIndex : c.loop ? 0 : -1] try { if (track) await this.packages.ensureAsset(pkg, track.assetId, this.materialAbort.signal) if (generation === this.generation && report.session === this.controller.state.session) send(this.controller, { action: 'next', channel: 'music' }) } catch (failure) { this.state.preparation.error = failure instanceof Error ? failure.message : 'Следующий трек недоступен' } } this.state.connection = 'offline' this.state.error = error instanceof Error ? error.message : 'Сервер не подтвердил переход музыки' } finally { this.advanceBusy = false } } async close() { this.cancellation?.abort() this.packages.cancel() this.detach() await Promise.allSettled([ this.materialRefresh, this.materialWork, this.mainWork, this.musicWork, ]) } }