This commit is contained in:
2026-10-18 05:25:23 +07:00
parent f5e645ec00
commit 30999630f0
18 changed files with 1023 additions and 185 deletions
@@ -259,6 +259,15 @@ export type UploadPlaylistTrackRsp = {
playlist: Playlist | undefined;
};
export type ReorderPlaylistTracksReq = {
playlistID: number | undefined;
trackIDs: number[] | undefined;
};
export type ReorderPlaylistTracksRsp = {
playlist: Playlist | undefined;
};
export type DeletePlaylistTrackReq = {
playlistID: number | undefined;
trackID: number | undefined;
@@ -697,6 +706,7 @@ export type BindScreenReq = {
screenID: string | undefined;
accessToken: string | undefined;
gameID: number | undefined;
initializeAudio: boolean | undefined;
};
export type BindScreenRsp = {
@@ -1395,6 +1405,7 @@ export interface EveningDetectiveServer {
UpdatePlaylist(request: UpdatePlaylistReq): Promise<UpdatePlaylistRsp>;
DeletePlaylist(request: DeletePlaylistReq): Promise<DeletePlaylistRsp>;
UploadPlaylistTrack(request: UploadPlaylistTrackReq): Promise<UploadPlaylistTrackRsp>;
ReorderPlaylistTracks(request: ReorderPlaylistTracksReq): Promise<ReorderPlaylistTracksRsp>;
DeletePlaylistTrack(request: DeletePlaylistTrackReq): Promise<DeletePlaylistTrackRsp>;
SetScenarioPlaylist(request: SetScenarioPlaylistReq): Promise<SetScenarioPlaylistRsp>;
AddScenario(request: AddScenarioReq): Promise<AddScenarioRsp>;
@@ -2119,6 +2130,26 @@ export function createEveningDetectiveServerClient(
method: "UploadPlaylistTrack",
}) as Promise<UploadPlaylistTrackRsp>;
},
ReorderPlaylistTracks(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}/tracks/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: "ReorderPlaylistTracks",
}) as Promise<ReorderPlaylistTracksRsp>;
},
DeletePlaylistTrack(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.playlistID) {
throw new Error("missing required field request.playlistID");
+21 -1
View File
@@ -163,7 +163,7 @@ test('музыка независима от основного канала, mu
assert.equal(c.state.channels.main.position, 20)
assert.equal(c.state.channels.music.desired, 'playing')
assert.equal(c.state.channels.music.position, 15)
assert.equal(c.state.channels.music.volume, 35)
assert.equal(c.state.channels.music.volume, 100)
send(c, { action: 'mute', enabled: false })
assert.equal(c.state.channels.main.volume, 80)
assert.throws(
@@ -237,3 +237,23 @@ test('Range: полный файл, границы, суффикс и отказ
for (const value of ['bytes=100-', 'bytes=20-10', 'bytes=-0', 'bytes=0-1,3-5', 'bytes=-', 'abc'])
assert.equal(parseRange(value, 100), null)
})
test('обновление порядка сохраняет текущий файл и операцию, следующий трек берётся из нового порядка', () => {
const c = controller()
const pkg = testPackage()
c.selectPackage(pkg)
send(c, { action: 'play', channel: 'music' })
ack(c, 'music', 'playing', 23)
const before = structuredClone(c.state.channels.music)
pkg.package.playlists[0]!.tracks.reverse()
c.selectPackage(pkg, true)
const after = c.state.channels.music
assert.equal(after.trackIndex, 1)
assert.equal(after.source?.url, before.source?.url)
assert.equal(after.operation, before.operation)
assert.equal(after.position, 23)
assert.equal(after.desired, 'playing')
send(c, { action: 'loop', channel: 'music', enabled: true })
send(c, { action: 'next', channel: 'music' })
assert.equal(c.state.channels.music.trackIndex, 0)
})
+5 -1
View File
@@ -144,7 +144,7 @@ function initialChannel(): ChannelState {
status: 'idle',
position: 0,
duration: null,
volume: 35,
volume: 100,
error: '',
playlistId: null,
trackIndex: 0,
@@ -268,6 +268,10 @@ export class Controller {
if (preserve && catalog.key === this.state.catalog.key) {
catalog.cover = this.state.catalog.cover
catalog.background = this.state.catalog.background
const music = this.state.channels.music
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
this.state.catalog = catalog
this.broadcast()
return
+15 -2
View File
@@ -1,5 +1,6 @@
import {
app,
dialog,
BrowserWindow,
ipcMain,
protocol,
@@ -10,8 +11,8 @@ import {
powerSaveBlocker,
} from 'electron'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import { readFile, stat } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { Readable } from 'node:stream'
import { DiagnosticLog } from './diagnostics.js'
import { Controller, parseCommand } from './controller.js'
@@ -304,6 +305,18 @@ app
},
undefined,
(failure) => diagnostics?.record('request-error', failure),
async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'MP3', extensions: ['mp3'] }],
})
const path = result.filePaths[0]
if (result.canceled || !path) return null
const info = await stat(path)
if (!info.isFile() || !/\.mp3$/i.test(path) || !info.size || info.size > 64 * 1048576)
throw new Error('Выберите MP3 размером до 64 МиБ')
return { filename: basename(path), data: await readFile(path) }
},
)
await online.initialize()
let previousConnection = online.state.connection
+120 -4
View File
@@ -34,6 +34,19 @@ async function fixture(t: TestContext, secure = true) {
},
}
const model = {
library: [
{
ID: 1,
name: 'Музыка',
tracks: [
{ ID: 1, name: 'Один' },
{ ID: 2, name: 'Два' },
],
},
],
playlistError: 0,
upload: null as { filename: string; data: Buffer } | null,
bindings: [] as Record<string, unknown>[],
offline: false,
server: 'https://server.test',
introURL: 'https://media.test/intro.wav',
@@ -233,7 +246,34 @@ async function fixture(t: TestContext, secure = true) {
}
return json({})
}
if (path.endsWith('/bind') || path.endsWith('/sound')) return json({})
if (path.endsWith('/bind')) {
model.bindings.push(JSON.parse(String(init?.body)))
return json({})
}
if (path.endsWith('/sound')) return json({})
if (path.startsWith('/api/playlists')) {
if (model.playlistError) return json({}, model.playlistError)
const data = init?.body ? JSON.parse(String(init.body)) : {}
if (path === '/api/playlists') {
if (init?.method === 'POST') model.library.push({ ID: 2, name: data.name, tracks: [] })
return json({ playlists: model.library })
}
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()
if (path === '/api/playlists/1/tracks/order') {
const tracks = model.library[0]!.tracks
model.library[0]!.tracks = data.trackIDs.map((id: number) =>
tracks.find((t) => t.ID === id),
)
}
if (path === '/api/playlists/1/tracks' && init?.method === 'POST') {
assert.equal(Buffer.from(data.data, 'base64').toString(), 'ID3test')
model.library[0]!.tracks.push({ ID: 3, name: data.name })
}
if (path === '/api/playlists/1/tracks/3' && init?.method === 'DELETE')
model.library[0]!.tracks.pop()
return json({})
}
throw new Error(`Unexpected request ${path}`)
}
const store = new PackageStore(join(directory, 'packages'), {
@@ -243,7 +283,15 @@ async function fixture(t: TestContext, secure = true) {
})
await store.initialize()
const controller = new Controller(() => {})
const online = new Online(join(directory, 'connection'), store, controller, secrets, fetchAPI)
const online = new Online(
join(directory, 'connection'),
store,
controller,
secrets,
fetchAPI,
undefined,
async () => model.upload,
)
await online.initialize()
shutdown = () => online.close()
const login = () =>
@@ -721,7 +769,7 @@ test('вход без прав управления не сохраняет то
test('назначение звука подтверждается сервером отдельно от локального mute и сбрасывается с сессией', async (t) => {
const f = await fixture(t)
await f.prepare()
assert.equal(f.online.state.soundEnabled, null)
assert.equal(f.online.state.soundEnabled, true)
f.controller.output(true)
f.model.remote.soundEnabled = false
await f.online.poll()
@@ -743,7 +791,7 @@ test('назначение звука подтверждается сервер
await f.online.poll()
assert.equal(f.online.state.soundEnabled, false)
await f.online.act({ action: 'connect' })
assert.equal(f.online.state.soundEnabled, null)
assert.equal(f.online.state.soundEnabled, true)
await f.online.poll()
await f.online.act({ action: 'disconnect' })
assert.equal(f.online.state.soundEnabled, null)
@@ -1332,3 +1380,71 @@ test('без истории и медиа текст, таймер и музык
assert.equal(f.online.snapshot().connection, 'connected')
assert.equal(f.online.snapshot().preparation.error, '')
})
test('явное подключение включает оба канала на 100%, дальнейшие команды и reconnect сохраняют власть сервера', async (t) => {
const f = await fixture(t)
await f.prepare()
assert.equal(f.model.bindings[0]!.initializeAudio, true)
assert.equal(f.controller.state.muted, false)
assert.equal(f.controller.state.channels.main.volume, 100)
assert.equal(f.controller.state.channels.music.volume, 100)
f.controller.output(true)
f.model.remote.volume = 27
f.model.remote.soundEnabled = false
await f.online.poll()
assert.equal(f.controller.state.channels.main.volume, 27)
assert.equal(f.controller.state.muted, true)
f.model.offline = true
await f.online.poll()
f.model.offline = false
await f.online.poll()
assert.equal(f.model.bindings.length, 1)
assert.equal(f.controller.state.channels.main.volume, 27)
assert.equal(f.controller.state.muted, true)
})
test('редактор музыки: CRUD, native MP3, порядок, валидация и конфликт не меняют текущий канал', async (t) => {
const f = await fixture(t)
await f.login()
await f.online.act({ action: 'playlists' })
assert.equal(f.online.snapshot().playlists[0]!.tracks.length, 2)
const before = structuredClone(f.controller.state.channels.music)
await f.online.act({ action: 'playlist-create', name: 'Новый' })
assert.equal(f.online.state.playlists[1]!.name, 'Новый')
await f.online.act({ action: 'playlist-rename', playlistId: 1, name: 'Новое имя' })
await f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [2, 1] })
assert.deepEqual(
f.online.state.playlists[0]!.tracks.map((t) => t.id),
[2, 1],
)
assert.deepEqual(f.controller.state.channels.music, before)
await assert.rejects(
f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [1, 1] }),
/повторяться/,
)
await assert.rejects(
f.online.act({ action: 'playlist-upload', playlistId: '../etc/passwd' }),
/число|Некоррект/,
)
f.model.upload = { filename: 'wrong.wav', data: Buffer.from('ID3test') }
await assert.rejects(f.online.act({ action: 'playlist-upload', playlistId: 1 }), /MP3/)
f.model.upload = { filename: 'large.mp3', data: Buffer.alloc(64 * 1048576 + 1) }
await assert.rejects(f.online.act({ action: 'playlist-upload', playlistId: 1 }), /64 МиБ/)
f.model.upload = { filename: 'test.mp3', data: Buffer.from('ID3test') }
await f.online.act({ action: 'playlist-upload', playlistId: 1 })
assert.equal(f.online.state.playlists[0]!.tracks.length, 3)
await f.online.act({ action: 'playlist-track-delete', playlistId: 1, trackId: 3 })
await f.online.act({ action: 'playlist-delete', playlistId: 2 })
assert.equal(f.online.state.playlists.length, 1)
f.model.playlistError = 409
await assert.rejects(
f.online.act({ action: 'playlist-reorder', playlistId: 1, trackIds: [1, 2] }),
/Состав треков изменился/,
)
assert.deepEqual(
f.online.state.playlists[0]!.tracks.map((t) => t.id),
[2, 1],
)
f.model.playlistError = 403
await assert.rejects(f.online.act({ action: 'playlist-create', name: 'Нет прав' }), /Нет доступа/)
})
+110 -3
View File
@@ -72,6 +72,7 @@ export class Online {
busy: false,
canCancel: false,
games: [],
playlists: [],
screenId: null,
soundEnabled: null,
connection: 'disabled',
@@ -336,7 +337,8 @@ export class Online {
if (
music &&
old &&
JSON.stringify(old.music?.filenames) !== JSON.stringify(next.music?.filenames)
old.music?.trackId === next.music?.trackId &&
!this.musicSourceMatches(next)
)
base.music = null
if (!music && old && JSON.stringify(old.resources) !== JSON.stringify(next.resources))
@@ -813,6 +815,8 @@ export class Online {
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
@@ -975,7 +979,9 @@ export class Online {
}
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
const signal = AbortSignal.any([
AbortSignal.timeout(30_000),
AbortSignal.timeout(
init.method === 'POST' && /^\/api\/playlists\/\d+\/tracks$/.test(path) ? 120_000 : 30_000,
),
...(init.signal ? [init.signal] : []),
])
try {
@@ -1072,6 +1078,12 @@ export class Online {
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)
}
})
@@ -1101,6 +1113,16 @@ export class Online {
private async perform(data: Record<string, unknown>) {
const action = data.action as OnlineIntent['action']
switch (action) {
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':
@@ -1115,6 +1137,7 @@ export class Online {
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 })
@@ -1149,6 +1172,7 @@ export class Online {
this.credentials = null
this.state.authenticated = false
this.state.games = []
this.state.playlists = []
await this.persistSecrets()
return
case 'games':
@@ -1253,7 +1277,16 @@ export class Online {
token: token(result.accessToken),
gameId,
}
await this.client().BindScreen({ screenID: s.id, accessToken: s.token, gameID: s.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'
@@ -1277,6 +1310,80 @@ export class Online {
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<string, unknown>) {
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('Некорректный список игр')
+7
View File
@@ -122,6 +122,13 @@ export function requestErrorContext(
AdvanceScreenMusic: 'Следующий трек',
Login: 'Вход',
Refresh: 'Обновление сессии',
GetPlaylists: 'Список плейлистов',
CreatePlaylist: 'Создание плейлиста',
UpdatePlaylist: 'Название плейлиста',
DeletePlaylist: 'Удаление плейлиста',
UploadPlaylistTrack: 'Загрузка MP3',
DeletePlaylistTrack: 'Удаление трека',
ReorderPlaylistTracks: 'Порядок треков',
GetMe: 'Данные пользователя',
GetGames: 'Список игр',
CreateScreenSession: 'Создание экрана',