This commit is contained in:
2026-10-27 13:07:24 +07:00
parent 066134f6ef
commit 6e83c014e5
18 changed files with 1445 additions and 174 deletions
+34 -2
View File
@@ -335,8 +335,10 @@ test('автопауза не запускает остановленную му
c.selectPackage(testPackage())
send(c, { action: 'play', channel: 'music' })
send(c, { action: 'mode', mode: 2 })
if (reset === 'session') { c.output(false); c.output(true) }
else if (reset === 'game') c.selectPackage(testPackage())
if (reset === 'session') {
c.output(false)
c.output(true)
} else if (reset === 'game') c.selectPackage(testPackage())
else ack(c, 'music', 'error')
send(c, { action: 'mode', mode: 1 })
assert.notEqual(c.state.channels.music.desired, 'playing')
@@ -373,3 +375,33 @@ test('серверная синхронизация не отменяет лок
assert.equal(c.state.channels.music.desired, 'paused')
}
})
for (const endMode of ['repeat', 'stop', 'next'] as const) {
test(`локальная группа: ${endMode}, ручной выбор и остановка`, () => {
const pkg = testPackage()
pkg.package.playlists[0]!.groups = [
{ id: 1, name: 'First', position: 0, endMode },
{ id: 3, name: 'Empty', position: 1, endMode: 'repeat' },
{ id: 2, name: 'Final', position: 2, endMode: 'stop' },
]
pkg.package.playlists[0]!.tracks[0]!.groupId = 1
pkg.package.playlists[0]!.tracks[1]!.groupId = 2
const c = new Controller(() => {})
c.selectPackage(pkg)
c.output(true)
send(c, { action: 'group', channel: 'music', groupId: 1 })
assert.equal(c.state.channels.music.desired, 'playing')
assert.equal(c.state.channels.music.groupId, 1)
assert.throws(() => send(c, { action: 'group', channel: 'music', groupId: 3 }), /пуста/)
ack(c, 'music', 'ended', 4, 4)
assert.equal(c.state.channels.music.groupId, endMode === 'next' ? 2 : 1)
assert.equal(c.state.channels.music.trackIndex, endMode === 'next' ? 1 : 0)
assert.equal(c.state.channels.music.desired, endMode === 'stop' ? 'stopped' : 'playing')
send(c, { action: 'track', channel: 'music', trackIndex: 1 })
assert.equal(c.state.channels.music.groupId, 2)
send(c, { action: 'group', channel: 'music', groupId: 0 })
assert.equal(c.state.channels.music.groupId, 0)
assert.equal(c.state.channels.music.trackIndex, 0)
assert.equal(c.state.channels.music.desired, 'playing')
})
}
+80 -7
View File
@@ -1,3 +1,4 @@
import { nextMusicTrack } from '../src/utils/musicGroups.js'
import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks'
import { isDeepStrictEqual } from 'node:util'
@@ -104,6 +105,11 @@ export function parseCommand(value: unknown): Command {
throw new Error('Некорректный плейлист')
intent = { action: 'playlist', channel, playlistId: c.playlistId }
break
case 'group':
if (channel !== 'music' || !number(c.groupId, 0) || !Number.isSafeInteger(c.groupId))
throw new Error('Некорректная группа')
intent = { action: 'group', channel, groupId: c.groupId }
break
case 'track':
if (channel !== 'music' || !number(c.trackIndex, 0) || !Number.isSafeInteger(c.trackIndex))
throw new Error('Некорректный трек')
@@ -159,6 +165,7 @@ function initialChannel(): ChannelState {
error: '',
playlistId: null,
trackIndex: 0,
groupId: 0,
loop: false,
}
}
@@ -276,9 +283,11 @@ export class Controller {
playlists: pkg.package.playlists.map((p) => ({
id: p.id,
name: p.name,
...(p.groups ? { groups: p.groups } : {}),
tracks: p.tracks.map((t) => ({
id: t.id,
name: t.name,
...(t.groupId ? { groupId: t.groupId } : {}),
source: source(`track-${p.id}-${t.id}`, t.assetId, t.name, 'audio'),
})),
})),
@@ -291,6 +300,22 @@ export class Controller {
const tracks = catalog.playlists.find((p) => p.id === music.playlistId)?.tracks
const index = tracks?.findIndex((t) => t.source.url === music.source?.url) ?? -1
if (index >= 0) music.trackIndex = index
const playlist = catalog.playlists.find((p) => p.id === music.playlistId)
if (
music.groupId &&
(!playlist?.groups?.some((g) => g.id === music.groupId) ||
playlist.tracks[index]?.groupId !== music.groupId)
) {
this.change('music', {
groupId: 0,
desired: 'stopped',
status: 'stopped',
position: 0,
trackIndex: Math.max(index, 0),
source: tracks?.[index]?.source ?? null,
duration: null,
})
}
this.state.catalog = catalog
this.broadcast()
return
@@ -576,17 +601,34 @@ export class Controller {
}
private advance() {
const c = this.state.channels.music
const tracks = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks ?? []
if (!tracks.length) throw new Error('Плейлист пуст')
const next = c.trackIndex + 1
if (next < tracks.length || c.loop) this.track(next % tracks.length)
else
const playlist = this.state.catalog.playlists.find((p) => p.id === c.playlistId)
if (!playlist?.tracks.length) throw new Error('Плейлист пуст')
const next = nextMusicTrack(playlist, c.trackIndex, c.groupId, c.loop)
if (next) {
c.groupId = next.groupId
this.track(next.index)
} else if (c.groupId) {
this.change('music', { desired: 'stopped', status: 'stopped', position: 0 })
} else {
this.change('music', {
desired: 'paused',
status: 'ended',
position: c.duration ?? c.position,
})
}
}
synchronizeMusicGroup(groupId: number) {
const c = this.state.channels.music
const playlist = this.state.catalog.playlists.find((p) => p.id === c.playlistId)
if (
groupId &&
(!playlist?.groups?.some((group) => group.id === groupId) ||
playlist.tracks[c.trackIndex]?.groupId !== groupId)
)
throw new Error('Группа ожидает подготовки локальных материалов')
c.groupId = groupId
}
command(input: unknown) {
const command = parseCommand(input)
if (command.session !== this.state.session || command.sequence <= this.state.sequence) return
@@ -615,6 +657,7 @@ export class Controller {
if (!playlist) throw new Error('Плейлист не найден в выбранном пакете')
this.change(channel, {
playlistId: playlist.id,
groupId: 0,
trackIndex: 0,
source: playlist.tracks[0]?.source ?? null,
position: 0,
@@ -624,9 +667,38 @@ export class Controller {
})
break
}
case 'track':
case 'group': {
const playlist = this.state.catalog.playlists.find((p) => p.id === c.playlistId)
if (
!playlist ||
(command.groupId && !playlist.groups?.some((group) => group.id === command.groupId))
)
throw new Error('Группа не найдена')
const index = command.groupId
? playlist.tracks.findIndex((track) => track.groupId === command.groupId)
: 0
const track = playlist.tracks[index]
if (!track) throw new Error('Группа пуста')
this.change('music', {
groupId: command.groupId,
trackIndex: index,
source: track.source,
desired: 'playing',
status: 'pending',
position: 0,
duration: null,
})
break
}
case 'track': {
const track = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[
command.trackIndex
]
if (!track) throw new Error('Трек не найден')
c.groupId = track.groupId ?? 0
this.track(command.trackIndex)
break
}
case 'next':
this.advance()
break
@@ -724,7 +796,8 @@ export class Controller {
}
if (report.status === 'error') {
if (report.channel === 'music') this.overrideMusic()
c.desired = 'paused'
c.desired = report.channel === 'music' && c.groupId ? 'stopped' : 'paused'
if (report.channel === 'music' && c.groupId) c.position = 0
if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) {
this.state.scene.status = 'error'
this.state.scene.error = c.error
+1 -1
View File
@@ -683,7 +683,7 @@ app
const parsed = parseCommand(command)
if (
!output &&
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'next'].includes(parsed.action)
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'next', 'group'].includes(parsed.action)
)
throw new Error('Сначала откройте окно показа')
if (parsed.session !== controller.state.session) return
+53
View File
@@ -498,3 +498,56 @@ for (const mode of [2, 4] as const) {
assert.equal(media.volume, 0.8)
})
}
for (const endMode of ['repeat', 'stop', 'next'] as const) {
test(`DOM: предзагрузка и окончание группы ${endMode}`, async (t) => {
const f = await fixture(t)
const playlist = f.c.state.catalog.playlists[0]!
playlist.groups = [
{ id: 1, name: 'First', position: 0, endMode },
{ id: 2, name: 'Final', position: 1, endMode: 'stop' },
]
playlist.tracks[0]!.groupId = 1
playlist.tracks[1]!.groupId = 2
send(f.c, { action: 'group', channel: 'music', groupId: 1 })
f.apply()
await setImmediate()
const active = [f.a, f.b].find((media) => !media.paused)!
const spare = [f.a, f.b].find((media) => media !== active)!
assert.ok(active)
assert.equal(
spare.src,
endMode === 'stop' ? '' : playlist.tracks[endMode === 'next' ? 1 : 0]!.source.url,
)
active.finish()
f.c.report(f.reports.at(-1)!)
f.apply()
await setImmediate()
assert.equal(f.c.state.channels.music.groupId, endMode === 'next' ? 2 : 1)
assert.equal(f.c.state.channels.music.desired, endMode === 'stop' ? 'stopped' : 'playing')
assert.equal([f.a, f.b].filter((media) => !media.paused).length, endMode === 'stop' ? 0 : 1)
})
}
test('DOM: отсутствующий следующий файл останавливает выбранную группу с ошибкой', async (t) => {
const f = await fixture(t)
const playlist = f.c.state.catalog.playlists[0]!
playlist.groups = [{ id: 1, name: 'First', position: 0, endMode: 'repeat' }]
playlist.tracks[0]!.groupId = 1
playlist.tracks[1]!.groupId = 1
playlist.tracks[1]!.source.ready = false
send(f.c, { action: 'group', channel: 'music', groupId: 1 })
f.apply()
await setImmediate()
const active = [f.a, f.b].find((media) => !media.paused)!
active.finish()
f.c.report(f.reports.at(-1)!)
f.apply()
await setImmediate()
const failed = [f.a, f.b].find((media) => media.src === playlist.tracks[1]!.source.url)!
failed.dispatchEvent(new Event('error'))
assert.equal(f.reports.at(-1)!.status, 'error')
f.c.report(f.reports.at(-1)!)
assert.equal(f.c.state.channels.music.groupId, 1)
assert.equal(f.c.state.channels.music.desired, 'stopped')
})
+55
View File
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { nextMusicTrack } from '../src/utils/musicGroups.js'
import { parsePlaylistGroups } from './playlist-groups.js'
import type { MusicGroup } from '../src/types/screen.js'
const groups: MusicGroup[] = [
{ id: 1, name: 'First', position: 0, endMode: 'repeat' },
{ id: 2, name: 'Empty', position: 1, endMode: 'repeat' },
{ id: 3, name: 'Final', position: 2, endMode: 'stop' },
]
for (const tc of [
{ name: 'within group', index: 0, groupId: 1, mode: 'stop', want: { index: 2, groupId: 1 } },
{ name: 'repeat', index: 2, groupId: 1, mode: 'repeat', want: { index: 0, groupId: 1 } },
{ name: 'stop overrides whole loop', index: 2, groupId: 1, mode: 'stop', want: null },
{ name: 'skip empty', index: 2, groupId: 1, mode: 'next', want: { index: 1, groupId: 3 } },
{ name: 'last stops', index: 1, groupId: 3, mode: 'repeat', want: null },
{ name: 'whole playlist', index: 0, groupId: 0, mode: 'stop', want: { index: 1, groupId: 0 } },
{
name: 'whole playlist loops',
index: 2,
groupId: 0,
mode: 'stop',
want: { index: 0, groupId: 0 },
},
] as const) {
test(`next music: ${tc.name}`, () => {
const playlist = {
tracks: [{ groupId: 1 }, { groupId: 3 }, { groupId: 1 }],
groups: groups.map((group) => (group.id === 1 ? { ...group, endMode: tc.mode } : group)),
}
assert.deepEqual(nextMusicTrack(playlist, tc.index, tc.groupId, true), tc.want)
})
}
test('group parser normalizes omitted repeat and sorts by position', () => {
assert.deepEqual(
parsePlaylistGroups([
{ ID: 2, name: 'B', position: 5, endMode: 2 },
{ ID: 1, name: 'A' },
]).map((group) => [group.id, group.endMode]),
[
[1, 'repeat'],
[2, 'next'],
],
)
for (const value of [
[{ ID: 1, name: 'A', endMode: 'unknown' }],
[
{ ID: 1, name: 'A' },
{ ID: 1, name: 'B' },
],
[{ ID: 1, name: 'A', position: -1 }],
])
assert.throws(() => parsePlaylistGroups(value))
})
+295 -21
View File
@@ -43,7 +43,12 @@ async function fixture(t: TestContext, secure = true) {
{ ID: 2, name: 'Два' },
],
},
],
] as {
ID: number
name: string
groups?: { ID: number; name: string; position: number; endMode?: string }[]
tracks: { ID: number; name: string; groupId?: number }[]
}[],
playlistError: 0,
upload: null as { filename: string; data: Buffer } | null,
bindings: [] as Record<string, unknown>[],
@@ -67,6 +72,7 @@ async function fixture(t: TestContext, secure = true) {
refreshCalls: 0,
refreshResponse: null as (() => Promise<Response>) | null,
advanceCalls: 0,
advanceHandler: null as (() => Promise<Response>) | null,
advanceRequest: null as Record<string, unknown> | null,
advanceResponse: null as Record<string, unknown> | null,
advanceError: 0,
@@ -144,6 +150,7 @@ async function fixture(t: TestContext, secure = true) {
if (path === '/api/screens/music/advance') {
model.advanceCalls++
model.advanceRequest = JSON.parse(String(init?.body)) as Record<string, unknown>
if (model.advanceHandler) return model.advanceHandler()
if (model.advanceError) return json({}, model.advanceError)
return json({ state: model.advanceResponse ?? objectMusic(3, 1) })
}
@@ -260,6 +267,8 @@ async function fixture(t: TestContext, secure = true) {
const state = objectMusic(20, Number(body.trackIndex ?? 0))
model.remote.music = {
...state,
playlist:
(model.remote.music as { playlist?: unknown } | undefined)?.playlist ?? state.playlist,
playback: body.playback,
volume: body.volume,
loop: body.loop,
@@ -275,11 +284,13 @@ async function fixture(t: TestContext, secure = true) {
{
ID: 1,
name: 'Музыка',
groups: model.library[0]?.groups,
tracks: [0, 1].map((i) => ({
ID: i + 1,
name: `Трек ${i}`,
filename: `https://media.test/${i}.wav`,
position: i,
groupId: model.library[0]?.tracks.find((track) => track.ID === i + 1)?.groupId,
})),
},
],
@@ -311,6 +322,35 @@ async function fixture(t: TestContext, secure = true) {
}
if (path === '/api/playlists/1' && init?.method === 'PUT') model.library[0]!.name = data.name
if (path === '/api/playlists/2' && init?.method === 'DELETE') model.library.pop()
const playlist = model.library[0]!
if (path === '/api/playlists/1/groups' && init?.method === 'POST') {
playlist.groups ??= []
const current = playlist.groups.find((group) => group.ID === data.groupID)
if (current) Object.assign(current, { name: data.name, endMode: data.endMode })
else
playlist.groups.push({
ID: Math.max(0, ...playlist.groups.map((group) => group.ID)) + 1,
name: data.name,
position: playlist.groups.length,
endMode: data.endMode,
})
}
if (path === '/api/playlists/1/groups/order') {
playlist.groups = data.groupIDs.map((id: number, position: number) => ({
...playlist.groups!.find((group) => group.ID === id)!,
position,
}))
}
if (/^\/api\/playlists\/1\/groups\/\d+$/.test(path) && init?.method === 'DELETE') {
const groupId = Number(path.split('/').at(-1))
playlist.groups = playlist.groups?.filter((group) => group.ID !== groupId)
playlist.tracks.forEach((track) => {
if (track.groupId === groupId) delete track.groupId
})
}
if (/^\/api\/playlists\/1\/tracks\/\d+\/group$/.test(path))
playlist.tracks.find((track) => track.ID === Number(path.split('/')[5]))!.groupId =
data.groupID
if (path === '/api/playlists/1/tracks/order') {
const tracks = model.library[0]!.tracks
model.library[0]!.tracks = data.trackIDs.map((id: number) =>
@@ -679,17 +719,26 @@ test('отмена подготовки не ждёт общий refresh и не
}
const cancelled = f.online.act({ action: 'check', gameId: 7 }).then(
() => 'completed',
(error: Error) => { assert.match(error.message, /отмен|ABORTED/); return 'cancelled' },
(error: Error) => {
assert.match(error.message, /отмен|ABORTED/)
return 'cancelled'
},
)
await refreshStarted
const background = f.online.refreshPlan()
await new Promise<void>((resolve) => setImmediate(resolve))
await f.online.act({ action: 'cancel' })
try {
assert.equal(await Promise.race([
cancelled,
new Promise<string>((resolve) => { const timer = setTimeout(() => resolve('still waiting'), 1000); timer.unref() }),
]), 'cancelled')
assert.equal(
await Promise.race([
cancelled,
new Promise<string>((resolve) => {
const timer = setTimeout(() => resolve('still waiting'), 1000)
timer.unref()
}),
]),
'cancelled',
)
assert.equal(f.model.refreshed, false)
} finally {
release()
@@ -2247,46 +2296,271 @@ test('список команд обновляется, сохраняется
assert.equal(c.state.teams, null)
})
function groupedMusic(version: number, index = 0, endMode = 'PLAYLIST_GROUP_END_MODE_REPEAT') {
return {
...objectMusic(version, index),
groupId: index === 0 ? 42 : 43,
playlist: {
ID: 1,
name: 'Группы',
groups: [
{ ID: 42, name: 'Сбор', position: 0, endMode },
{ ID: 44, name: 'Пустая', position: 1 },
{ ID: 43, name: 'Финал', position: 2, endMode: 'PLAYLIST_GROUP_END_MODE_STOP' },
],
tracks: [0, 1].map((i) => ({
ID: i + 1,
name: `Трек ${i}`,
filename: `https://media.test/${i}.wav`,
position: i,
groupId: i === 0 ? 42 : 43,
})),
},
}
}
for (const action of ['volume', 'pause', 'resume', 'replay'] as const) {
test(`группа сохраняется при команде ${action} нативного плеера`, async (t) => {
const f = await fixture(t)
const music = groupedMusic(2)
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
f.model.remote.music = { ...objectMusic(2), groupId: 42 }
await f.online.poll()
await f.online.planCommand(action === 'volume' ? { action, channel: 'music', volume: 55 } : { action, channel: 'music' })
assert.equal((f.model.remote.music as { groupId: number }).groupId, 42)
await assert.rejects(f.online.planCommand({ action: 'next', channel: 'music' }), /активной группы/)
await assert.rejects(f.online.planCommand({ action: 'track', channel: 'music', trackIndex: 1 }), /активной группы/)
await assert.rejects(f.online.planCommand({ action: 'loop', channel: 'music', enabled: true }), /активной группы/)
await assert.rejects(
f.online.planCommand({ action: 'loop', channel: 'music', enabled: true }),
/редакторе/,
)
await f.online.planCommand({ action: 'track', channel: 'music', trackIndex: 1 })
assert.equal(f.controller.state.channels.music.groupId, 43)
})
}
test('повтор одного трека группы передаёт точную версию и перезапускает трек', async (t) => {
const f = await fixture(t)
const music = { ...groupedMusic(2), stateVersion: '9007199254740995', positionMs: 0 }
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
const version = '9007199254740995'
f.model.remote.music = { ...objectMusic(2), stateVersion: version, groupId: 42, positionMs: 0 }
await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
const before = f.controller.state.channels.music.operation
f.model.advanceResponse = { ...objectMusic(3), stateVersion: '9007199254740996', groupId: 42, positionMs: 0 }
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: before, status: 'ended', position: 0.1, duration: 0.1 })
assert.equal(f.model.advanceRequest?.expectedStateVersion, version)
f.model.advanceResponse = { ...groupedMusic(3), stateVersion: '9007199254740996', positionMs: 0 }
await f.online.musicEnded({
session: f.controller.state.session,
channel: 'music',
operation: before,
status: 'ended',
position: 0.1,
duration: 0.1,
})
assert.equal(f.model.advanceRequest?.expectedStateVersion, music.stateVersion)
assert.ok(f.controller.state.channels.music.operation > before)
assert.equal(f.controller.state.channels.music.trackIndex, 0)
assert.equal(f.controller.state.channels.music.groupId, 42)
})
test('группа останавливается при потере связи вместо выхода в общий список', async (t) => {
for (const [mode, index, groupId, desired] of [
['PLAYLIST_GROUP_END_MODE_REPEAT', 0, 42, 'playing'],
['PLAYLIST_GROUP_END_MODE_STOP', 0, 42, 'stopped'],
['PLAYLIST_GROUP_END_MODE_NEXT', 1, 43, 'playing'],
] as const) {
test(`офлайн-завершение группы: ${mode}`, async (t) => {
const f = await fixture(t)
const music = groupedMusic(2, 0, mode)
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
f.model.advanceError = 503
await f.online.musicEnded({
session: f.controller.state.session,
channel: 'music',
operation: f.controller.state.channels.music.operation,
status: 'ended',
position: 4,
duration: 4,
})
assert.equal(f.controller.state.channels.music.desired, desired)
assert.equal(f.controller.state.channels.music.groupId, groupId)
assert.equal(f.controller.state.channels.music.trackIndex, index)
await f.online.poll()
assert.equal(
f.controller.state.channels.music.groupId,
42,
'server wins on reconnect even with unchanged music version',
)
assert.equal(f.controller.state.channels.music.trackIndex, 0)
})
}
test('редактор сохраняет группы и переносит треки через существующие API', async (t) => {
const f = await fixture(t)
await f.login()
await f.online.act({
action: 'group-save',
playlistId: 1,
groupId: 0,
name: 'Сбор',
endMode: 'repeat',
})
await f.online.act({
action: 'group-save',
playlistId: 1,
groupId: 0,
name: 'Финал',
endMode: 'stop',
})
const groups = f.online.snapshot().playlists[0]!.groups!
assert.deepEqual(
groups.map((g) => g.endMode),
['repeat', 'stop'],
)
await f.online.act({ action: 'track-group', playlistId: 1, trackId: 1, groupId: groups[0]!.id })
assert.equal(f.online.snapshot().playlists[0]!.tracks[0]!.groupId, groups[0]!.id)
await f.online.act({
action: 'group-reorder',
playlistId: 1,
groupIds: [groups[1]!.id, groups[0]!.id],
})
assert.equal(f.online.snapshot().playlists[0]!.groups![0]!.name, 'Финал')
await f.online.act({
action: 'group-save',
playlistId: 1,
groupId: groups[0]!.id,
name: 'Гости',
endMode: 'next',
})
assert.equal(f.online.snapshot().playlists[0]!.groups![1]!.endMode, 'next')
await f.online.act({ action: 'group-delete', playlistId: 1, groupId: groups[0]!.id })
assert.equal(f.online.snapshot().playlists[0]!.tracks[0]!.groupId, undefined)
assert.equal(f.online.snapshot().playlists[0]!.tracks.length, 2)
})
test('запоздалая ошибка подготовки группы не останавливает новую группу', async (t) => {
const f = await fixture(t)
const music = groupedMusic(2)
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
await f.online.poll()
const pkg = f.store.description(f.store.list()[0]!.key)!.package
const target = pkg.playlists[0]!.tracks[0]!.assetId
const canRead = f.store.canRead.bind(f.store),
ensure = f.store.ensureAsset.bind(f.store)
let reject!: (error: Error) => void,
started = false,
shouldDefer = true
const pending = new Promise<void>((_resolve, failure) => {
reject = failure
})
f.store.canRead = (key, id) => (id === target && shouldDefer ? false : canRead(key, id))
f.store.ensureAsset = (...args) => {
if (args[1] === target && shouldDefer) {
shouldDefer = false
started = true
return pending
}
return ensure(...args)
}
f.model.offline = true
const old = f.online.planCommand({ action: 'group', channel: 'music', groupId: 42 })
await eventually(() => started, 'старая загрузка началась')
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 43 })
const operation = f.controller.state.channels.music.operation
reject(new Error('old preparation failure'))
await old
assert.equal(f.controller.state.channels.music.groupId, 43)
assert.equal(f.controller.state.channels.music.desired, 'playing')
assert.equal(f.controller.state.channels.music.operation, operation)
})
test('запоздалая ошибка офлайн-перехода не останавливает новый запуск', async (t) => {
const f = await fixture(t)
const music = groupedMusic(2, 0, 'PLAYLIST_GROUP_END_MODE_NEXT')
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }
f.model.remote.music = music
await f.prepare()
f.controller.output(true)
f.model.remote.music = { ...objectMusic(2), groupId: 42 }
await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
const pkg = f.store.description(f.store.list()[0]!.key)!.package
const target = pkg.playlists[0]!.tracks[1]!.assetId
const ensure = f.store.ensureAsset.bind(f.store)
let reject!: (error: Error) => void,
started = false,
shouldDefer = true
const pending = new Promise<void>((_resolve, failure) => {
reject = failure
})
f.store.ensureAsset = (...args) => {
if (args[1] === target && shouldDefer) {
shouldDefer = false
started = true
return pending
}
return ensure(...args)
}
f.model.advanceError = 503
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: f.controller.state.channels.music.operation, status: 'ended', position: 4, duration: 4 })
assert.equal(f.controller.state.channels.music.desired, 'stopped')
assert.equal(f.controller.state.channels.music.trackIndex, 0)
const old = f.online.musicEnded({
session: f.controller.state.session,
channel: 'music',
operation: f.controller.state.channels.music.operation,
status: 'ended',
position: 4,
duration: 4,
})
await eventually(() => started, 'офлайн-переход готовит файл')
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 42 })
const operation = f.controller.state.channels.music.operation
reject(new Error('old next failure'))
await old
assert.equal(f.controller.state.channels.music.groupId, 42)
assert.equal(f.controller.state.channels.music.desired, 'playing')
assert.equal(f.controller.state.channels.music.operation, operation)
assert.equal(f.online.snapshot().connection, 'connected')
})
test('ожидающее окончание старой группы не блокирует окончание новой короткой группы', async (t) => {
const f = await fixture(t); const music = groupedMusic(2)
f.model.musicOverride = { gamePlaylistId: 1, playlists: [music.playlist] }; f.model.remote.music = music
await f.prepare(); f.controller.output(true); await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
let resolve!: (response: Response) => void
const pending = new Promise<Response>((done) => { resolve = done })
let calls = 0
f.model.advanceHandler = () => ++calls === 1 ? pending : Promise.resolve(Response.json({ state: { ...groupedMusic(21, 1), playback: 'MUSIC_PLAYBACK_STOPPED', positionMs: 0 } }))
const report = () => ({ session: f.controller.state.session, channel: 'music' as const, operation: f.controller.state.channels.music.operation, status: 'ended' as const, position: .1, duration: .1 })
const old = f.online.musicEnded(report())
await eventually(() => calls === 1, 'старый RPC ожидает ответа')
await f.online.planCommand({ action: 'group', channel: 'music', groupId: 43 })
await f.online.musicEnded(report())
assert.equal(calls, 2)
assert.equal(f.controller.state.channels.music.groupId, 43)
assert.equal(f.controller.state.channels.music.desired, 'stopped')
resolve(Response.json({ state: groupedMusic(3) })); await old
assert.equal(f.controller.state.channels.music.groupId, 43)
assert.equal(f.controller.state.channels.music.desired, 'stopped')
})
test('группа без подготовленного локального описания не продолжает общий плейлист', async (t) => {
const f = await fixture(t)
await f.prepare(); f.controller.output(true); await f.online.poll()
send(f.controller, { action: 'play', channel: 'music' })
assert.equal(f.controller.state.channels.music.groupId, 0)
f.model.remote.music = groupedMusic(2)
await f.online.poll()
assert.equal(f.controller.state.channels.music.desired, 'stopped')
assert.match(f.online.snapshot().preparation.error, /Группа/)
f.model.offline = true
await f.online.poll()
await f.online.musicEnded({ session: f.controller.state.session, channel: 'music', operation: f.controller.state.channels.music.operation, status: 'ended', position: 4, duration: 4 })
assert.equal(f.controller.state.channels.music.trackIndex, 0)
assert.equal(f.model.advanceCalls, 0)
})
+141 -32
View File
@@ -1,3 +1,5 @@
import { nextMusicTrack } from '../src/utils/musicGroups.js'
import { parsePlaylistGroups, apiGroupEndMode } from './playlist-groups.js'
import { requestErrorContext } from './request-error.js'
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import { apiScreenMode, apiScreenCommand, apiPlan, apiTimerType } from './api-values.js'
@@ -369,6 +371,7 @@ export class Online {
const channel = this.controller.state.channels.music
return (
next.music?.playlistId === channel.playlistId &&
next.music?.groupId === channel.groupId &&
(id ? `screen-app://app/packages/${this.selectedGame()?.key}/${id}` : undefined) ===
channel.source?.url
)
@@ -461,9 +464,17 @@ export class Online {
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 : 'Материал не подготовлен'
if (
generation === this.generation &&
session === this.controller.state.session &&
version === this.planVersion &&
!this.materialAbort.signal.aborted
) {
const current = this.controller.state.channels.music
if (music && next.music && next.music.groupId !== current.groupId && current.source && this.received?.music?.version === next.music.version)
send(this.controller, { action: 'stop', channel: 'music' })
this.state.preparation.error = error instanceof Error ? error.message : 'Материал не подготовлен'
}
}
}
private synchronize() {
@@ -490,7 +501,7 @@ export class Online {
private teamsBusy = false
private lastResults = -Infinity
private resultsBusy = false
private advanceBusy = false
private musicAdvance: { session: string; operation: number } | null = null
private plan: ScreenPlan = { steps: [], currentStep: 0 }
private planBusy = 0
private planCommands: Promise<boolean> = Promise.resolve(true)
@@ -603,8 +614,12 @@ export class Online {
: intent.action === 'playlist'
? 0
: channel.trackIndex
if (intent.action === 'group' && playlist)
index = intent.groupId
? playlist.tracks.findIndex((track) => track.groupId === intent.groupId)
: 0
if (intent.action === 'next' && playlist)
index = (index + 1) % Math.max(1, playlist.tracks.length)
index = nextMusicTrack(playlist, index, channel.groupId, channel.loop)?.index ?? -1
const track = playlist?.tracks[index]
return track ? [track.assetId] : []
}
@@ -624,14 +639,13 @@ export class Online {
async planCommand(intent: Intent) {
if (this.busy) throw new Error('Подождите завершения текущего действия')
const pkg = this.selectedGame()
const activeMusic = this.received?.music
// shortcut: native track navigation and completion controls have no group catalogue; use website group controls until it does.
if (
activeMusic?.groupId &&
'channel' in intent && intent.channel === 'music' &&
(intent.action === 'next' || intent.action === 'track' || intent.action === 'loop')
'channel' in intent &&
intent.channel === 'music' &&
intent.action === 'loop' &&
this.controller.state.channels.music.groupId
)
throw new Error('Треки и режим завершения активной группы меняются через сайт. Для обычного списка выберите весь плейлист.')
throw new Error('Режим завершения группы меняется в редакторе')
const localVersion = ++this.localCommandVersion
const localGeneration = this.generation
const localSession = this.controller.state.session
@@ -641,9 +655,25 @@ export class Online {
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)),
)
try {
await Promise.all(
ids.map((id) => this.packages.ensureAsset(pkg.package, id, this.materialAbort.signal)),
)
} catch (error) {
if (
localVersion !== this.localCommandVersion ||
localGeneration !== this.generation ||
localSession !== this.controller.state.session
)
return
if (
'channel' in intent &&
intent.channel === 'music' &&
(intent.action === 'group' || this.controller.state.channels.music.groupId)
)
send(this.controller, { action: 'stop', channel: 'music' })
throw error
}
if (
localVersion !== this.localCommandVersion ||
localGeneration !== this.generation ||
@@ -679,9 +709,18 @@ export class Online {
if (generation !== this.generation || session !== this.controller.state.session) return
if (
version !== this.planVersion &&
['step', 'mode', 'text', 'qr', 'select', 'timer-set', 'playlist', 'track', 'next'].includes(
intent.action,
)
[
'step',
'mode',
'text',
'qr',
'select',
'timer-set',
'playlist',
'track',
'next',
'group',
].includes(intent.action)
)
return
if (hasPendingCommand && !wasAvailable) {
@@ -715,7 +754,7 @@ export class Online {
await this.client(true, AbortSignal.timeout(5000)).SetGameMusic({
gameID: pkg.package.game.id,
playlistID: c.playlistId ?? 0,
groupId: c.playlistId === activeMusic?.playlistId ? activeMusic.groupId : 0,
groupId: c.groupId,
trackIndex: c.trackIndex,
positionMs: Math.max(0, Math.round(c.position * 1000)),
volume: Math.round(c.volume),
@@ -876,6 +915,8 @@ export class Online {
remote.music?.playlistId,
remote.music?.trackIds,
remote.music?.filenames,
remote.music?.groups,
remote.music?.trackGroupIds,
])
if (!('channel' in intent && intent.channel === 'music')) {
if (needsMainSync) {
@@ -897,6 +938,7 @@ export class Online {
m.playlistId === c.playlistId &&
this.musicSourceMatches(remote) &&
m.trackId === expectedTrack &&
m.groupId === c.groupId &&
m.volume === Math.round(c.volume) &&
m.loop === c.loop &&
m.playback === (c.desired === 'playing' ? 2 : c.desired === 'paused' ? 3 : 1) &&
@@ -921,6 +963,8 @@ export class Online {
this.previous = null
this.planNotice = 'Команда выполнена локально; сервер не подтвердил обновление'
if (!(error instanceof NetworkError)) throw error
if ('channel' in intent && intent.channel === 'music') this.musicApplied = null
this.state.connection = 'offline'
} finally {
this.planBusy--
complete(isAvailable)
@@ -1302,6 +1346,10 @@ export class Online {
'playlist-use': 'Подключение плейлиста к игре…',
'playlist-track-delete': 'Удаление трека…',
'playlist-reorder': 'Сохранение порядка треков…',
'group-save': 'Сохранение группы…',
'group-delete': 'Удаление группы…',
'group-reorder': 'Сохранение порядка групп…',
'track-group': 'Перенос трека…',
'plan-save': 'Сохранение плана экранов…',
'plan-refresh': 'Загрузка плана экранов…',
login: 'Вход на сервер…',
@@ -1364,6 +1412,10 @@ export class Online {
case 'playlist-upload':
case 'playlist-track-delete':
case 'playlist-reorder':
case 'group-save':
case 'group-delete':
case 'group-reorder':
case 'track-group':
case 'playlist-use':
return this.editPlaylist(data)
case 'plan-save':
@@ -1626,12 +1678,17 @@ export class Online {
this.state.playlists = (result.playlists ?? []).map((value) => {
const p = object(value)
if (!Array.isArray(p.tracks ?? [])) throw new Error('Некорректный список треков')
const groups = parsePlaylistGroups(p.groups)
return {
id: integer(p.ID, 1),
name: text(p.name),
...(groups.length ? { groups } : {}),
tracks: ((p.tracks ?? []) as unknown[]).map((value) => {
const t = object(value)
return { id: integer(t.ID, 1), name: text(t.name) }
const groupId = integer(t.groupId ?? 0, 0, 2147483647)
if (groupId && !groups.some((group) => group.id === groupId))
throw new Error('Группа трека не найдена')
return { id: integer(t.ID, 1), name: text(t.name), ...(groupId ? { groupId } : {}) }
}),
}
})
@@ -1646,6 +1703,34 @@ export class Online {
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 === 'group-save') {
const name = text(data.name).trim()
if (!name || Buffer.byteLength(name) > 255) throw new Error('Имя группы: от 1 до 255 байт')
const endMode = text(data.endMode)
if (endMode !== 'repeat' && endMode !== 'stop' && endMode !== 'next')
throw new Error('Некорректный режим группы')
await client.SavePlaylistGroup({
playlistID: id,
groupID: integer(data.groupId, 0, 2147483647),
name,
endMode: apiGroupEndMode(endMode),
})
} else if (data.action === 'group-delete') {
await client.DeletePlaylistGroup({
playlistID: id,
groupID: integer(data.groupId, 1, 2147483647),
})
} else if (data.action === 'group-reorder') {
if (!Array.isArray(data.groupIds)) throw new Error('Некорректный порядок групп')
const ids = data.groupIds.map((value) => integer(value, 1, 2147483647))
if (new Set(ids).size !== ids.length) throw new Error('Группы не должны повторяться')
await client.ReorderPlaylistGroups({ playlistID: id, groupIDs: ids })
} else if (data.action === 'track-group') {
await client.SetPlaylistTrackGroup({
playlistID: id,
trackID: integer(data.trackId, 1, 2147483647),
groupID: integer(data.groupId, 0, 2147483647),
})
} else if (data.action === 'playlist-delete') await client.DeletePlaylist({ ID: id })
else if (data.action === 'playlist-track-delete')
await client.DeletePlaylistTrack({
@@ -1690,6 +1775,7 @@ export class Online {
playback: 'MUSIC_PLAYBACK_STOPPED',
})
}
if (generation !== this.generation) throw new SessionChangedError()
await this.loadPlaylists()
this.lastMaterials = -Infinity
await this.refreshMaterials()
@@ -1855,6 +1941,8 @@ export class Online {
next.music?.playlistId,
next.music?.trackIds,
next.music?.filenames,
next.music?.groups,
next.music?.trackGroupIds,
])
this.state.soundEnabled = next.sound
if (this.lastSound !== next.sound)
@@ -1896,14 +1984,15 @@ export class Online {
async musicEnded(report: Report) {
const c = this.controller.state.channels.music
if (
this.advanceBusy ||
(this.musicAdvance?.session === report.session && this.musicAdvance.operation === report.operation) ||
report.session !== this.controller.state.session ||
report.operation !== c.operation ||
c.desired !== 'playing'
)
return
const generation = this.generation
this.advanceBusy = true
const advance = { session: report.session, operation: report.operation }
this.musicAdvance = advance
try {
if (!this.screen || this.state.connection !== 'connected') throw new NetworkError('Нет связи')
if (this.received && !this.musicSourceMatches(this.received)) {
@@ -1915,7 +2004,12 @@ export class Online {
accessToken: this.screen.token,
expectedStateVersion: this.received?.music?.version.toString(),
})
if (generation !== this.generation || report.session !== this.controller.state.session) return
if (
generation !== this.generation ||
report.session !== this.controller.state.session ||
report.operation !== this.controller.state.channels.music.operation
)
return
const music = parseRemote({ music: result.state }).music
if (this.received) {
if (music && this.received.music && music.version > this.received.music.version && music.trackId === this.received.music.trackId)
@@ -1924,20 +2018,35 @@ export class Online {
await this.applyReceived(true)
}
} catch (error) {
if (generation !== this.generation || report.session !== this.controller.state.session) return
if (
generation !== this.generation ||
report.session !== this.controller.state.session ||
report.operation !== this.controller.state.channels.music.operation
)
return
this.musicApplied = null
const pkg = this.selectedGame()?.package
const playlist = pkg?.playlists.find((p) => p.id === c.playlistId)
if (this.received?.music?.groupId) {
send(this.controller, { action: 'stop', channel: 'music' })
} else if (pkg && playlist) {
const nextIndex = c.trackIndex + 1
const track =
playlist.tracks[nextIndex < playlist.tracks.length ? nextIndex : c.loop ? 0 : -1]
if (pkg && playlist) {
const next = nextMusicTrack(playlist, c.trackIndex, c.groupId, c.loop)
const track = next ? playlist.tracks[next.index] : undefined
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' })
if (
generation !== this.generation ||
report.session !== this.controller.state.session ||
report.operation !== this.controller.state.channels.music.operation
)
return
send(this.controller, { action: 'next', channel: 'music' })
} catch (failure) {
if (
generation !== this.generation ||
report.session !== this.controller.state.session ||
report.operation !== this.controller.state.channels.music.operation
)
return
send(this.controller, { action: 'stop', channel: 'music' })
this.state.preparation.error =
failure instanceof Error ? failure.message : 'Следующий трек недоступен'
}
@@ -1946,7 +2055,7 @@ export class Online {
this.state.error =
error instanceof Error ? error.message : 'Сервер не подтвердил переход музыки'
} finally {
this.advanceBusy = false
if (this.musicAdvance === advance) this.musicAdvance = null
}
}
async close() {
+65
View File
@@ -0,0 +1,65 @@
import type { MusicGroup } from '../src/types/screen.js'
import { fieldError } from './request-error.js'
export function groupEndMode(value: unknown): MusicGroup['endMode'] {
if (value == null || value === 0 || value === 'PLAYLIST_GROUP_END_MODE_REPEAT') return 'repeat'
if (value === 1 || value === 'PLAYLIST_GROUP_END_MODE_STOP') return 'stop'
if (value === 2 || value === 'PLAYLIST_GROUP_END_MODE_NEXT') return 'next'
throw fieldError('groups.endMode', 'режим завершения группы', value, 'Некорректный режим группы')
}
export function apiGroupEndMode(value: MusicGroup['endMode']) {
return value === 'stop'
? ('PLAYLIST_GROUP_END_MODE_STOP' as const)
: value === 'next'
? ('PLAYLIST_GROUP_END_MODE_NEXT' as const)
: ('PLAYLIST_GROUP_END_MODE_REPEAT' as const)
}
export function parsePlaylistGroups(value: unknown): MusicGroup[] {
if (value == null) return []
if (!Array.isArray(value))
throw fieldError('groups', 'массив', value, 'Некорректный список групп')
const seen = new Set<number>()
return value
.map((raw, index) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
throw fieldError(`groups[${index}]`, 'объект', raw, 'Некорректная группа')
const group = raw as Record<string, unknown>
if (!Number.isSafeInteger(group.ID) || Number(group.ID) <= 0 || seen.has(Number(group.ID)))
throw fieldError(
`groups[${index}].ID`,
'уникальный положительный ID',
group.ID,
'Некорректная группа',
)
if (
typeof group.name !== 'string' ||
!group.name.trim() ||
Buffer.byteLength(group.name) > 255
)
throw fieldError(
`groups[${index}].name`,
'название группы',
group.name,
'Некорректная группа',
)
const position = group.position ?? 0
if (!Number.isSafeInteger(position) || Number(position) < 0)
throw fieldError(
`groups[${index}].position`,
'неотрицательное целое',
position,
'Некорректный порядок групп',
)
const id = Number(group.ID)
seen.add(id)
return {
id,
name: group.name,
position: Number(position),
endMode: groupEndMode(group.endMode),
}
})
.sort((a, b) => a.position - b.position || a.id - b.id)
}
+15
View File
@@ -1,3 +1,5 @@
import { parsePlaylistGroups } from './playlist-groups.js'
import type { MusicGroup } from '../src/types/screen.js'
import { parseTimerType } from './screen-plan.js'
import { timerLabels, validQR } from '../src/utils/presentation.js'
import type { TimerType } from '../src/types/screen.js'
@@ -82,6 +84,8 @@ export interface RemoteState {
groupId: number
trackId: number | null
trackIds: number[]
groups: MusicGroup[]
trackGroupIds: number[]
filenames: string[]
position: number
volume: number
@@ -161,6 +165,15 @@ export function parseRemote(value: unknown): RemoteState {
playlistId: p ? integer(p.ID, 1, Number.MAX_SAFE_INTEGER, 'state.music.playlist.ID') : null,
trackId: trackIds[index] ?? null,
trackIds,
groups: parsePlaylistGroups(p?.groups),
trackGroupIds: tracks.map((track, i) =>
integer(
object(track).groupId ?? 0,
0,
Number.MAX_SAFE_INTEGER,
`state.music.playlist.tracks[${i}].groupId`,
),
),
filenames: tracks.map((t, i) =>
text(object(t).filename ?? '', `state.music.playlist.tracks[${i}].filename`),
),
@@ -300,6 +313,7 @@ function applyRemoteState(controller: Controller, previous: RemoteState, next: R
!old ||
old.playlistId !== m.playlistId ||
old.trackId !== m.trackId ||
old.groupId !== m.groupId ||
old.playback !== m.playback ||
old.position !== m.position
if (transportChanged) {
@@ -312,6 +326,7 @@ function applyRemoteState(controller: Controller, previous: RemoteState, next: R
)
send(controller, { action: 'track', channel: 'music', trackIndex: index })
}
controller.synchronizeMusicGroup(m.groupId)
send(controller, { action: 'volume', channel: 'music', volume: m.volume })
send(controller, { action: 'loop', channel: 'music', enabled: m.loop })
if (transportChanged) {
+65
View File
@@ -501,3 +501,68 @@ test('ссылки состояния экрана определяют меди
assert.equal(coverOnly.media.background, null)
for (const value of [pkg, cleared, coverOnly]) assert.deepEqual(parseScreenPackage(value), value)
})
test('группы сохраняются в пакете и его отпечатке, старые пакеты остаются совместимыми', () => {
const legacy = build()
assert.ok(
legacy.playlists.every(
(playlist) =>
!('groups' in playlist) && playlist.tracks.every((track) => !('groupId' in track)),
),
)
assert.deepEqual(parseScreenPackage(JSON.parse(JSON.stringify(legacy))), legacy)
const data = fixture()
const selected = data.music.state.playlist
const playlist = {
...selected,
groups: [
{ ID: 42, name: 'First', position: 1 },
{ ID: 43, name: 'Final', position: 0, endMode: 'PLAYLIST_GROUP_END_MODE_STOP' },
],
tracks: selected.tracks.map((track) => ({ ...track, groupId: 42 })),
}
const grouped = buildScreenPackage(server, 7, {
...data,
music: { ...data.music, state: { ...data.music.state, playlist } },
})
assert.notEqual(grouped.metadataVersion, legacy.metadataVersion)
assert.deepEqual(parseScreenPackage(JSON.parse(JSON.stringify(grouped))), grouped)
assert.deepEqual(
grouped.playlists.find((p) => p.id === 3)!.groups!.map((group) => [group.id, group.endMode]),
[
[43, 'stop'],
[42, 'repeat'],
],
)
const renamed = buildScreenPackage(server, 7, {
...data,
music: {
...data.music,
state: {
...data.music.state,
playlist: {
...playlist,
groups: playlist.groups.map((group) => ({ ...group, name: group.name + ' changed' })),
},
},
},
})
assert.notEqual(renamed.metadataVersion, grouped.metadataVersion)
assert.throws(
() =>
buildScreenPackage(server, 7, {
...data,
music: {
...data.music,
state: {
...data.music.state,
playlist: {
...playlist,
tracks: playlist.tracks.map((track) => ({ ...track, groupId: 999 })),
},
},
},
}),
/Группа/,
)
})
+42 -3
View File
@@ -10,6 +10,8 @@ import { parseTimerType } from './screen-plan.js'
import { validQR } from '../src/utils/presentation.js'
import type { TimerType } from '../src/types/screen.js'
import { createHash } from 'node:crypto'
import type { MusicGroup } from '../src/types/screen.js'
import { parsePlaylistGroups, apiGroupEndMode } from './playlist-groups.js'
export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения'
@@ -24,7 +26,14 @@ export interface ScreenPackage {
intro: string | null
conclusion: string | null
}
steps: { mode: number; text: string; timerLabel: string; timerDurationSeconds: number; qrUrl?: string; timerType?: TimerType }[]
steps: {
mode: number
text: string
timerLabel: string
timerDurationSeconds: number
qrUrl?: string
timerType?: TimerType
}[]
music: {
scenarioPlaylistId: number | null
gamePlaylistId: number | null
@@ -33,7 +42,8 @@ export interface ScreenPackage {
playlists: {
id: number
name: string
tracks: { id: number; name: string; position: number; assetId: string }[]
groups?: MusicGroup[]
tracks: { id: number; name: string; position: number; groupId?: number; assetId: string }[]
}[]
assets: { id: string; url: string }[]
}
@@ -283,6 +293,7 @@ export function buildScreenPackage(
)
const playlist = entry.value
const field = entry.field
const groups = parsePlaylistGroups(playlist.groups)
const tracks = list(playlist.tracks, `${field}.tracks`)
.map((value, index) => {
const trackField = `${field}.tracks[${index}]`
@@ -299,11 +310,23 @@ export function buildScreenPackage(
id: id(track.ID, `${trackField}.ID`),
name: text(track.name, `${trackField}.name`),
position: integer(track.position, 0, `${trackField}.position`),
...(integer(track.groupId, 0, `${trackField}.groupId`)
? { groupId: integer(track.groupId, 0, `${trackField}.groupId`) }
: {}),
assetId,
}
})
.sort((a, b) => a.position - b.position || a.id - b.id)
return { id: playlistId, name: text(playlist.name, `${field}.name`), tracks }
for (const track of tracks) {
if (track.groupId && !groups.some((group) => group.id === track.groupId))
throw new Error('Группа трека отсутствует в плейлисте')
}
return {
id: playlistId,
name: text(playlist.name, `${field}.name`),
tracks,
...(groups.length ? { groups } : {}),
}
})
const data = {
formatVersion: 1 as const,
@@ -480,12 +503,28 @@ export function parseScreenPackage(value: unknown): ScreenPackage {
return {
ID: id(playlist.id),
name: text(playlist.name),
...(playlist.groups === undefined
? {}
: {
groups: list(playlist.groups).map((value) => {
const group = object(value)
if (!['repeat', 'stop', 'next'].includes(String(group.endMode)))
throw new Error('Некорректный режим группы пакета')
return {
ID: id(group.id),
name: text(group.name),
position: integer(group.position),
endMode: apiGroupEndMode(group.endMode as MusicGroup['endMode']),
}
}),
}),
tracks: list(playlist.tracks).map((value) => {
const track = object(value)
return {
ID: id(track.id),
name: text(track.name),
position: integer(track.position),
...(track.groupId === undefined ? {} : { groupId: integer(track.groupId) }),
filename: ref(track.assetId),
}
}),