This commit is contained in:
2026-10-27 12:03:36 +07:00
parent f268503da3
commit 066134f6ef
4 changed files with 216 additions and 4 deletions
@@ -2,6 +2,10 @@
/* eslint-disable camelcase */
// @ts-nocheck
export type PlaylistGroupEndMode =
| "PLAYLIST_GROUP_END_MODE_REPEAT"
| "PLAYLIST_GROUP_END_MODE_STOP"
| "PLAYLIST_GROUP_END_MODE_NEXT";
export type ScreenMode =
| "SCREEN_MODE_UNSPECIFIED"
| "SCREEN_MODE_COVER"
@@ -208,17 +212,65 @@ export type DownloadFileReq = {
filename: string | undefined;
};
export type PlaylistTrack = {
export type PlaylistGroup = {
ID: number | undefined;
name: string | undefined;
filename: string | undefined;
position: number | undefined;
endMode: PlaylistGroupEndMode | undefined;
};
export type SavePlaylistGroupReq = {
playlistID: number | undefined;
groupID: number | undefined;
name: string | undefined;
endMode: PlaylistGroupEndMode | undefined;
};
export type SavePlaylistGroupRsp = {
playlist: Playlist | undefined;
};
export type Playlist = {
ID: number | undefined;
name: string | undefined;
tracks: PlaylistTrack[] | undefined;
groups: PlaylistGroup[] | undefined;
};
export type PlaylistTrack = {
ID: number | undefined;
name: string | undefined;
filename: string | undefined;
position: number | undefined;
groupId: number | undefined;
};
export type DeletePlaylistGroupReq = {
playlistID: number | undefined;
groupID: number | undefined;
};
export type DeletePlaylistGroupRsp = {
playlist: Playlist | undefined;
};
export type ReorderPlaylistGroupsReq = {
playlistID: number | undefined;
groupIDs: number[] | undefined;
};
export type ReorderPlaylistGroupsRsp = {
playlist: Playlist | undefined;
};
export type SetPlaylistTrackGroupReq = {
playlistID: number | undefined;
trackID: number | undefined;
groupID: number | undefined;
};
export type SetPlaylistTrackGroupRsp = {
playlist: Playlist | undefined;
};
export type GetPlaylistsReq = {
@@ -649,6 +701,7 @@ export type MusicState = {
loop: boolean | undefined;
playback: MusicPlaybackState | undefined;
stateVersion: number | undefined;
groupId: number | undefined;
};
export type CreateScreenSessionReq = {
@@ -695,6 +748,7 @@ export type SetGameMusicReq = {
volume: number | undefined;
loop: boolean | undefined;
playback: MusicPlaybackState | undefined;
groupId: number | undefined;
};
export type SetGameMusicRsp = {
@@ -704,6 +758,7 @@ export type SetGameMusicRsp = {
export type AdvanceScreenMusicReq = {
screenID: string | undefined;
accessToken: string | undefined;
expectedStateVersion: string | undefined;
};
export type AdvanceScreenMusicRsp = {
@@ -1429,6 +1484,10 @@ export interface EveningDetectiveServer {
GetPermissions(request: GetPermissionsReq): Promise<GetPermissionsRsp>;
UploadFile(request: UploadFileReq): Promise<UploadFileRsp>;
DownloadFile(request: DownloadFileReq): Promise<googleapi_HttpBody>;
SavePlaylistGroup(request: SavePlaylistGroupReq): Promise<SavePlaylistGroupRsp>;
DeletePlaylistGroup(request: DeletePlaylistGroupReq): Promise<DeletePlaylistGroupRsp>;
ReorderPlaylistGroups(request: ReorderPlaylistGroupsReq): Promise<ReorderPlaylistGroupsRsp>;
SetPlaylistTrackGroup(request: SetPlaylistTrackGroupReq): Promise<SetPlaylistTrackGroupRsp>;
GetPlaylists(request: GetPlaylistsReq): Promise<GetPlaylistsRsp>;
CreatePlaylist(request: CreatePlaylistReq): Promise<CreatePlaylistRsp>;
UpdatePlaylist(request: UpdatePlaylistReq): Promise<UpdatePlaylistRsp>;
@@ -2066,6 +2125,92 @@ export function createEveningDetectiveServerClient(
method: "DownloadFile",
}) as Promise<googleapi_HttpBody>;
},
SavePlaylistGroup(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.playlistID) {
throw new Error("missing required field request.playlistID");
}
const path = `api/playlists/${request.playlistID}/groups`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "SavePlaylistGroup",
}) as Promise<SavePlaylistGroupRsp>;
},
DeletePlaylistGroup(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.playlistID) {
throw new Error("missing required field request.playlistID");
}
if (!request.groupID) {
throw new Error("missing required field request.groupID");
}
const path = `api/playlists/${request.playlistID}/groups/${request.groupID}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "DELETE",
body,
}, {
service: "EveningDetectiveServer",
method: "DeletePlaylistGroup",
}) as Promise<DeletePlaylistGroupRsp>;
},
ReorderPlaylistGroups(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.playlistID) {
throw new Error("missing required field request.playlistID");
}
const path = `api/playlists/${request.playlistID}/groups/order`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "PUT",
body,
}, {
service: "EveningDetectiveServer",
method: "ReorderPlaylistGroups",
}) as Promise<ReorderPlaylistGroupsRsp>;
},
SetPlaylistTrackGroup(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.playlistID) {
throw new Error("missing required field request.playlistID");
}
if (!request.trackID) {
throw new Error("missing required field request.trackID");
}
const path = `api/playlists/${request.playlistID}/tracks/${request.trackID}/group`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "PUT",
body,
}, {
service: "EveningDetectiveServer",
method: "SetPlaylistTrackGroup",
}) as Promise<SetPlaylistTrackGroupRsp>;
},
GetPlaylists(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/playlists`; // eslint-disable-line quotes
const body = null;
+51 -1
View File
@@ -67,6 +67,9 @@ async function fixture(t: TestContext, secure = true) {
refreshCalls: 0,
refreshResponse: null as (() => Promise<Response>) | null,
advanceCalls: 0,
advanceRequest: null as Record<string, unknown> | null,
advanceResponse: null as Record<string, unknown> | null,
advanceError: 0,
resultError: false,
teamsError: false,
screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }],
@@ -140,7 +143,9 @@ async function fixture(t: TestContext, secure = true) {
}
if (path === '/api/screens/music/advance') {
model.advanceCalls++
return json({ state: objectMusic(3, 1) })
model.advanceRequest = JSON.parse(String(init?.body)) as Record<string, unknown>
if (model.advanceError) return json({}, model.advanceError)
return json({ state: model.advanceResponse ?? objectMusic(3, 1) })
}
if (path === '/api/screens/teams') return model.teamsError ? json({}, 500) : json({ teams: model.screenTeams })
if (path === '/api/screens/results') {
@@ -258,6 +263,7 @@ async function fixture(t: TestContext, secure = true) {
playback: body.playback,
volume: body.volume,
loop: body.loop,
groupId: body.groupId,
positionMs: body.positionMs,
}
return json({ state: model.remote.music })
@@ -2240,3 +2246,47 @@ test('список команд обновляется, сохраняется
await restored.select(null)
assert.equal(c.state.teams, null)
})
for (const action of ['volume', 'pause', 'resume', 'replay'] as const) {
test(`группа сохраняется при команде ${action} нативного плеера`, async (t) => {
const f = await fixture(t)
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 }), /активной группы/)
})
}
test('повтор одного трека группы передаёт точную версию и перезапускает трек', async (t) => {
const f = await fixture(t)
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)
assert.ok(f.controller.state.channels.music.operation > before)
assert.equal(f.controller.state.channels.music.trackIndex, 0)
})
test('группа останавливается при потере связи вместо выхода в общий список', async (t) => {
const f = await fixture(t)
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' })
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)
})
+16 -1
View File
@@ -624,6 +624,14 @@ 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')
)
throw new Error('Треки и режим завершения активной группы меняются через сайт. Для обычного списка выберите весь плейлист.')
const localVersion = ++this.localCommandVersion
const localGeneration = this.generation
const localSession = this.controller.state.session
@@ -707,6 +715,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,
trackIndex: c.trackIndex,
positionMs: Math.max(0, Math.round(c.position * 1000)),
volume: Math.round(c.volume),
@@ -1673,6 +1682,7 @@ export class Online {
await client.SetGameMusic({
gameID: pkg.package.game.id,
playlistID: id,
groupId: 0,
trackIndex: 0,
positionMs: 0,
volume: this.controller.state.channels.music.volume,
@@ -1903,10 +1913,13 @@ export class Online {
const result = await this.client(false, AbortSignal.timeout(5000)).AdvanceScreenMusic({
screenID: this.screen.id,
accessToken: this.screen.token,
expectedStateVersion: this.received?.music?.version.toString(),
})
if (generation !== this.generation || report.session !== this.controller.state.session) 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)
this.musicApplied = null
this.received = { ...this.received, music }
await this.applyReceived(true)
}
@@ -1914,7 +1927,9 @@ export class Online {
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) {
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]
+2
View File
@@ -79,6 +79,7 @@ export interface RemoteState {
music: {
version: bigint
playlistId: number | null
groupId: number
trackId: number | null
trackIds: number[]
filenames: string[]
@@ -156,6 +157,7 @@ export function parseRemote(value: unknown): RemoteState {
const index = integer(m.trackIndex ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.trackIndex')
music = {
version: version(m.stateVersion, 'state.music.stateVersion'),
groupId: integer(m.groupId ?? 0, 0, Number.MAX_SAFE_INTEGER, 'state.music.groupId'),
playlistId: p ? integer(p.ID, 1, Number.MAX_SAFE_INTEGER, 'state.music.playlist.ID') : null,
trackId: trackIds[index] ?? null,
trackIds,