102 lines
3.5 KiB
TypeScript
102 lines
3.5 KiB
TypeScript
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<string>();
|
|
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<Response> {
|
|
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<Uint8Array>, {
|
|
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'
|
|
}
|
|
});
|
|
}
|