feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user