Files
DndGamePlayer/app/main/playlists/playlistsStore.ts
Ivan Fontosh 278eb78637 feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 10:37:21 +08:00

425 lines
14 KiB
TypeScript

import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type {
AppPlaylist,
PlaylistAudio,
PlaylistTrack,
PlaylistsExportBundle,
PlaylistsState,
} from '../../shared/types/appPlaylists';
import {
normalizePlaylistName,
titleFromAudioFileName,
} from '../../shared/types/appPlaylists';
import {
asPlaylistAudioId,
asPlaylistId,
asPlaylistTrackId,
type PlaylistAudioId,
type PlaylistId,
type PlaylistTrackId,
} from '../../shared/types/ids';
type PlaylistsManifest = {
playlists: AppPlaylist[];
audios: PlaylistAudio[];
};
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
function mimeFromExt(ext: string): string {
const e = ext.toLowerCase();
if (e === '.mp3') return 'audio/mpeg';
if (e === '.wav') return 'audio/wav';
if (e === '.ogg') return 'audio/ogg';
if (e === '.m4a') return 'audio/mp4';
if (e === '.aac') return 'audio/aac';
return 'application/octet-stream';
}
function safeFileBase(name: string): string {
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
return base || 'audio';
}
function randomPlaylistId(): PlaylistId {
return asPlaylistId(`pl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
function randomAudioId(): PlaylistAudioId {
return asPlaylistAudioId(`pla_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
function randomTrackId(): PlaylistTrackId {
return asPlaylistTrackId(`plt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
function uniquePlaylistName(desired: string, playlists: AppPlaylist[], exceptId?: PlaylistId): string {
const base = normalizePlaylistName(desired);
const taken = new Set(
playlists.filter((p) => p.id !== exceptId).map((p) => p.name.trim().toLowerCase()),
);
if (!taken.has(base.toLowerCase())) return base;
let i = 2;
while (taken.has(`${base} (${String(i)})`.toLowerCase())) i += 1;
return `${base} (${String(i)})`;
}
export class PlaylistsStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private playlists: AppPlaylist[] = [];
private audios: PlaylistAudio[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'playlists');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'playlists.json');
}
async ensureLoaded(): Promise<void> {
if (this.loaded) return;
await fs.mkdir(this.filesDir, { recursive: true });
try {
const raw = await fs.readFile(this.manifestPath, 'utf8');
const parsed = JSON.parse(raw) as PlaylistsManifest;
this.audios = Array.isArray(parsed.audios) ? parsed.audios : [];
const audioIds = new Set(this.audios.map((a) => a.id));
this.playlists = (Array.isArray(parsed.playlists) ? parsed.playlists : []).map((p) => ({
id: asPlaylistId(String(p.id)),
name: normalizePlaylistName(String(p.name ?? '')),
tracks: (Array.isArray(p.tracks) ? p.tracks : [])
.filter((t) => audioIds.has(t.audioId))
.map((t) => ({
id: asPlaylistTrackId(String(t.id)),
audioId: asPlaylistAudioId(String(t.audioId)),
})),
}));
} catch {
this.playlists = [];
this.audios = [];
await this.persist();
}
this.loaded = true;
}
getState(): PlaylistsState {
return {
playlists: this.playlists.map((p) => ({ ...p, tracks: [...p.tracks] })),
audios: [...this.audios],
};
}
getAudioReadInfo(id: PlaylistAudioId): { absPath: string; mime: string } | null {
const audio = this.audios.find((a) => a.id === id);
if (!audio) return null;
return {
absPath: path.join(this.rootDir, audio.relPath),
mime: audio.mime || mimeFromExt(path.extname(audio.relPath)),
};
}
getAudioUrl(id: PlaylistAudioId): string | null {
if (!this.getAudioReadInfo(id)) return null;
return `dnd://playlist-audio?id=${encodeURIComponent(id)}`;
}
private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
const manifest: PlaylistsManifest = { playlists: this.playlists, audios: this.audios };
await fs.writeFile(this.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}
async createPlaylist(name?: string): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl: AppPlaylist = {
id: randomPlaylistId(),
name: uniquePlaylistName(name ?? 'Playlist', this.playlists),
tracks: [],
};
this.playlists = [pl, ...this.playlists];
await this.persist();
return pl;
}
async renamePlaylist(id: PlaylistId, name: string): Promise<AppPlaylist> {
await this.ensureLoaded();
const idx = this.playlists.findIndex((p) => p.id === id);
if (idx < 0) throw new Error('Playlist not found');
const next = {
...this.playlists[idx]!,
name: uniquePlaylistName(name, this.playlists, id),
};
this.playlists = this.playlists.map((p, i) => (i === idx ? next : p));
await this.persist();
return next;
}
async duplicatePlaylist(id: PlaylistId): Promise<AppPlaylist> {
await this.ensureLoaded();
const src = this.playlists.find((p) => p.id === id);
if (!src) throw new Error('Playlist not found');
const copy: AppPlaylist = {
id: randomPlaylistId(),
name: uniquePlaylistName(`${src.name} copy`, this.playlists),
tracks: src.tracks.map((t) => ({ id: randomTrackId(), audioId: t.audioId })),
};
const at = this.playlists.findIndex((p) => p.id === id);
const next = [...this.playlists];
next.splice(at + 1, 0, copy);
this.playlists = next;
await this.persist();
return copy;
}
async deletePlaylist(id: PlaylistId): Promise<void> {
await this.ensureLoaded();
this.playlists = this.playlists.filter((p) => p.id !== id);
await this.pruneOrphanAudios();
await this.persist();
}
async setPlaylistsOrder(playlistIds: PlaylistId[]): Promise<void> {
await this.ensureLoaded();
const map = new Map(this.playlists.map((p) => [p.id, p]));
const next: AppPlaylist[] = [];
for (const id of playlistIds) {
const p = map.get(id);
if (p) {
next.push(p);
map.delete(id);
}
}
for (const p of map.values()) next.push(p);
this.playlists = next;
await this.persist();
}
async setTracksOrder(playlistId: PlaylistId, trackIds: PlaylistTrackId[]): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl = this.playlists.find((p) => p.id === playlistId);
if (!pl) throw new Error('Playlist not found');
const map = new Map(pl.tracks.map((t) => [t.id, t]));
const tracks: PlaylistTrack[] = [];
for (const id of trackIds) {
const t = map.get(id);
if (t) {
tracks.push(t);
map.delete(id);
}
}
for (const t of map.values()) tracks.push(t);
const next = { ...pl, tracks };
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
await this.persist();
return next;
}
async renameAudio(audioId: PlaylistAudioId, title: string): Promise<PlaylistAudio> {
await this.ensureLoaded();
const idx = this.audios.findIndex((a) => a.id === audioId);
if (idx < 0) throw new Error('Audio not found');
const next = { ...this.audios[idx]!, title: title.trim() || this.audios[idx]!.title };
this.audios = this.audios.map((a, i) => (i === idx ? next : a));
await this.persist();
return next;
}
async removeTrack(playlistId: PlaylistId, trackId: PlaylistTrackId): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl = this.playlists.find((p) => p.id === playlistId);
if (!pl) throw new Error('Playlist not found');
const next = { ...pl, tracks: pl.tracks.filter((t) => t.id !== trackId) };
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
await this.pruneOrphanAudios();
await this.persist();
return next;
}
async addAudioFiles(playlistId: PlaylistId, filePaths: string[]): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl = this.playlists.find((p) => p.id === playlistId);
if (!pl) throw new Error('Playlist not found');
const tracks = [...pl.tracks];
for (const filePath of filePaths) {
const ext = path.extname(filePath).toLowerCase();
if (!AUDIO_EXTS.has(ext)) continue;
const audio = await this.importOrReuseFile(filePath);
tracks.push({ id: randomTrackId(), audioId: audio.id });
}
const next = { ...pl, tracks };
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
await this.persist();
return next;
}
/** Импорт файла с известным содержимым (Foundry / zip bundle). */
async importAudioBuffer(input: {
preferredId?: PlaylistAudioId;
title: string;
originalName: string;
mime: string;
buffer: Buffer;
}): Promise<PlaylistAudio> {
await this.ensureLoaded();
const sha256 = crypto.createHash('sha256').update(input.buffer).digest('hex');
const existing = this.audios.find((a) => a.sha256 === sha256);
if (existing) return existing;
const id =
input.preferredId && !this.audios.some((a) => a.id === input.preferredId)
? input.preferredId
: randomAudioId();
const ext = path.extname(input.originalName).toLowerCase() || '.mp3';
const fileName = `${id}_${safeFileBase(input.title)}${AUDIO_EXTS.has(ext) ? ext : '.mp3'}`;
const abs = path.join(this.filesDir, fileName);
await fs.writeFile(abs, input.buffer);
const audio: PlaylistAudio = {
id,
title: input.title.trim() || titleFromAudioFileName(input.originalName),
originalName: input.originalName,
relPath: path.join('files', fileName).replace(/\\/gu, '/'),
sha256,
mime: input.mime || mimeFromExt(ext),
sizeBytes: input.buffer.length,
};
this.audios = [...this.audios, audio];
await this.persist();
return audio;
}
async importBundle(
bundle: PlaylistsExportBundle,
readFile: (relPath: string) => Promise<Buffer>,
): Promise<{ importedPlaylistIds: PlaylistId[] }> {
await this.ensureLoaded();
const audioIdMap = new Map<PlaylistAudioId, PlaylistAudioId>();
for (const a of bundle.audios ?? []) {
try {
const buf = await readFile(a.relPath);
const imported = await this.importAudioBuffer({
title: a.title,
originalName: a.originalName,
mime: a.mime,
buffer: buf,
});
audioIdMap.set(a.id, imported.id);
} catch {
/* skip missing file */
}
}
const importedPlaylistIds: PlaylistId[] = [];
for (const pl of bundle.playlists ?? []) {
const tracks: PlaylistTrack[] = [];
for (const t of pl.tracks ?? []) {
const mapped = audioIdMap.get(t.audioId);
if (!mapped) continue;
tracks.push({ id: randomTrackId(), audioId: mapped });
}
const created: AppPlaylist = {
id: randomPlaylistId(),
name: uniquePlaylistName(pl.name, this.playlists),
tracks,
};
this.playlists = [...this.playlists, created];
importedPlaylistIds.push(created.id);
}
await this.persist();
return { importedPlaylistIds };
}
buildExportBundle(playlistIds: PlaylistId[]): {
bundle: PlaylistsExportBundle;
fileAbsPaths: { relPath: string; absPath: string }[];
} {
const selected = this.playlists.filter((p) => playlistIds.includes(p.id));
const usedAudioIds = new Set<PlaylistAudioId>();
for (const p of selected) {
for (const t of p.tracks) usedAudioIds.add(t.audioId);
}
const audios = this.audios.filter((a) => usedAudioIds.has(a.id));
const fileAbsPaths = audios.map((a) => ({
relPath: a.relPath,
absPath: path.join(this.rootDir, a.relPath),
}));
return {
bundle: { version: 1, playlists: selected, audios },
fileAbsPaths,
};
}
async packForExport(playlistIds: PlaylistId[], exportRoot: string): Promise<void> {
await this.ensureLoaded();
if (playlistIds.length === 0) return;
const { bundle, fileAbsPaths } = this.buildExportBundle(playlistIds);
if (bundle.playlists.length === 0) return;
const outDir = path.join(exportRoot, 'app-playlists');
await fs.mkdir(outDir, { recursive: true });
for (const f of fileAbsPaths) {
const dest = path.join(outDir, f.relPath);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(f.absPath, dest);
}
await fs.writeFile(
path.join(outDir, 'playlists.json'),
`${JSON.stringify(bundle, null, 2)}\n`,
'utf8',
);
}
async importFromExportDir(sourceCache: string): Promise<void> {
await this.ensureLoaded();
const manifestPath = path.join(sourceCache, 'app-playlists', 'playlists.json');
let raw: string;
try {
raw = await fs.readFile(manifestPath, 'utf8');
} catch {
return;
}
let bundle: PlaylistsExportBundle;
try {
bundle = JSON.parse(raw) as PlaylistsExportBundle;
} catch {
return;
}
await this.importBundle(bundle, async (relPath) => {
return fs.readFile(path.join(sourceCache, 'app-playlists', relPath));
});
}
private async importOrReuseFile(filePath: string): Promise<PlaylistAudio> {
const buf = await fs.readFile(filePath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const existing = this.audios.find((a) => a.sha256 === sha256);
if (existing) return existing;
const originalName = path.basename(filePath);
const ext = path.extname(filePath).toLowerCase();
return this.importAudioBuffer({
title: titleFromAudioFileName(originalName),
originalName,
mime: mimeFromExt(ext),
buffer: buf,
});
}
private async pruneOrphanAudios(): Promise<void> {
const used = new Set<PlaylistAudioId>();
for (const p of this.playlists) {
for (const t of p.tracks) used.add(t.audioId);
}
const orphans = this.audios.filter((a) => !used.has(a.id));
this.audios = this.audios.filter((a) => used.has(a.id));
for (const a of orphans) {
try {
await fs.unlink(path.join(this.rootDir, a.relPath));
} catch {
/* ignore */
}
}
}
}