278eb78637
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry. Co-authored-by: Cursor <cursoragent@cursor.com>
425 lines
14 KiB
TypeScript
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 */
|
|
}
|
|
}
|
|
}
|
|
}
|