Cache music as mp3 files on startup

This commit is contained in:
SirBlob committed 2026-11-01 01:27:47 -04:00
1 parent c819436481
commit 33c9daa58b
11 files changed
+158 -15

No files matched your search

+30 -12
View File
@@ -9,7 +9,7 @@ const mp3Bitrate = '160k';
const youtube = new Ytdl();
const playlistVideoIds = playlist.videos.flatMap((video) => {
export const playlistVideoIds = playlist.videos.flatMap((video) => {
const videoId = parseVideoId(video);
return videoId ? [videoId] : [];
});
@@ -30,7 +30,8 @@ async function getTrack(videoId: string): Promise<Track> {
title: videoDetails.title,
artist: videoDetails.author.name,
durationSeconds: videoDetails.lengthSeconds,
cover: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
cover: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
cached: false
};
trackCache.set(videoId, track);
return track;
@@ -45,7 +46,13 @@ export async function getPlaylistTracks(): Promise<Track[]> {
});
}
export function streamTrackAsMp3(videoId: string, startSeconds: number): ReadableStream<Uint8Array> {
export interface Transcode {
output: Readable;
finished: Promise<void>;
stop: () => void;
}
export function startTranscode(videoId: string, startSeconds: number): Transcode {
const downloadController = new AbortController();
const youtubeAudio = youtube.download(videoId, {
quality: Quality.HighestAudio,
@@ -62,17 +69,28 @@ export function streamTrackAsMp3(videoId: string, startSeconds: number): Readabl
if (ffmpeg.exitCode === null) ffmpeg.kill('SIGKILL');
};
youtubeAudio.on('error', (error) => {
console.error(`Music download failed for ${videoId}:`, error);
stop();
});
ffmpeg.on('error', (error) => {
console.error('ffmpeg failed to start:', error);
stop();
const finished = new Promise<void>((resolve, reject) => {
youtubeAudio.on('error', reject);
ffmpeg.on('error', reject);
ffmpeg.on('close', (exitCode) => (exitCode === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${exitCode}`))));
});
finished.catch(stop);
ffmpeg.stdin.on('error', stop);
ffmpeg.stdout.on('close', stop);
youtubeAudio.pipe(ffmpeg.stdin);
return Readable.toWeb(ffmpeg.stdout) as ReadableStream<Uint8Array>;
return { output: ffmpeg.stdout, finished, stop };
}
export function streamTrackAsMp3(videoId: string, startSeconds: number): ReadableStream<Uint8Array> {
const transcode = startTranscode(videoId, startSeconds);
let listenerLeft = false;
transcode.output.on('close', () => {
listenerLeft = true;
transcode.stop();
});
transcode.finished.catch((error) => {
if (!listenerLeft) console.error(`Music stream failed for ${videoId}:`, error.message);
});
return Readable.toWeb(transcode.output) as ReadableStream<Uint8Array>;
}
+100
View File
@@ -0,0 +1,100 @@
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) {
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'
}
});
}