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 { 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 { 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 { 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 { 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 { 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 { await this.ensureLoaded(); this.playlists = this.playlists.filter((p) => p.id !== id); await this.pruneOrphanAudios(); await this.persist(); } async setPlaylistsOrder(playlistIds: PlaylistId[]): Promise { 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 { 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 { 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 { 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 { 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 { 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, ): Promise<{ importedPlaylistIds: PlaylistId[] }> { await this.ensureLoaded(); const audioIdMap = new Map(); 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(); 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 { 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 { 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 { 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 { const used = new Set(); 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 */ } } } }