import { createReadStream, createWriteStream } from 'node:fs'; import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { playlistVideoIds, startTranscode } from './music'; const cacheDirectory = path.resolve(process.env.MUSIC_CACHE_DIR ?? '.cache/music'); const songExtension = '.mp3'; const partialExtension = '.part'; const rangePattern = /^bytes=(\d*)-(\d*)$/; const readySongs = new Set(); let refreshStarted = false; function songPath(videoId: string): string { return path.join(cacheDirectory, `${videoId}${songExtension}`); } export function isSongCached(videoId: string): boolean { return readySongs.has(videoId); } async function prepareCacheDirectory() { await mkdir(cacheDirectory, { recursive: true }); const playlistIds = new Set(playlistVideoIds); for (const fileName of await readdir(cacheDirectory)) { const videoId = fileName.slice(0, -songExtension.length); const isKeptSong = fileName.endsWith(songExtension) && playlistIds.has(videoId); if (isKeptSong) readySongs.add(videoId); else await rm(path.join(cacheDirectory, fileName), { force: true }); } } async function downloadSong(videoId: string) { const partialPath = `${songPath(videoId)}${partialExtension}`; const transcode = startTranscode(videoId, 0); try { await Promise.all([pipeline(transcode.output, createWriteStream(partialPath)), transcode.finished]); const { size } = await stat(partialPath); if (size === 0) throw new Error('ffmpeg produced an empty file'); await rename(partialPath, songPath(videoId)); readySongs.add(videoId); } catch (error) { transcode.stop(); await rm(partialPath, { force: true }); throw error; } } export function refreshMusicCache() { if (refreshStarted) return; refreshStarted = true; (async () => { await prepareCacheDirectory(); for (const videoId of playlistVideoIds) { if (readySongs.has(videoId)) continue; try { await downloadSong(videoId); console.log(`Music cached: ${videoId}`); } catch (error) { console.error(`Music cache failed for ${videoId}:`, error instanceof Error ? error.message : error); } } })().catch((error) => console.error('Music cache refresh failed:', error)); } function parseRange(rangeHeader: string | null, fileSize: number): { start: number; end: number } | null { const match = rangeHeader?.match(rangePattern); if (!match) return { start: 0, end: fileSize - 1 }; const [, startText, endText] = match; const start = startText ? Number(startText) : Math.max(0, fileSize - Number(endText)); const end = startText && endText ? Math.min(Number(endText), fileSize - 1) : fileSize - 1; return start <= end && start < fileSize ? { start, end } : null; } export async function serveCachedSong(videoId: string, rangeHeader: string | null): Promise { const filePath = songPath(videoId); const { size } = await stat(filePath); const range = parseRange(rangeHeader, size); if (!range) { return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}` } }); } const fileStream = createReadStream(filePath, { start: range.start, end: range.end }); return new Response(Readable.toWeb(fileStream) as ReadableStream, { status: rangeHeader ? 206 : 200, headers: { 'Content-Type': 'audio/mpeg', 'Content-Length': `${range.end - range.start + 1}`, 'Content-Range': `bytes ${range.start}-${range.end}/${size}`, 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=3600' } }); }