Cache music as mp3 files on startup
This commit is contained in:
1 parent
c819436481
commit
33c9daa58b
11 files changed
+158
-15
No files matched your search
@@ -15,6 +15,7 @@ UPDATE.md
|
|||||||
blogs
|
blogs
|
||||||
tools
|
tools
|
||||||
__pycache__
|
__pycache__
|
||||||
|
.cache
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -24,6 +24,7 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
bun.lock
|
bun.lock
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
.cache/
|
||||||
Update.md
|
Update.md
|
||||||
PROJECT.md
|
PROJECT.md
|
||||||
blogs/
|
blogs/
|
||||||
@@ -18,6 +18,7 @@ COPY package.json ./
|
|||||||
RUN bun install --production --ignore-scripts
|
RUN bun install --production --ignore-scripts
|
||||||
|
|
||||||
COPY --from=build /app/build ./build
|
COPY --from=build /app/build ./build
|
||||||
|
RUN mkdir -p /app/.cache/music && chown -R bun:bun /app/.cache
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ServerInit } from '@sveltejs/kit';
|
import type { ServerInit } from '@sveltejs/kit';
|
||||||
import { getDiscordClient } from '#lib/server/discord';
|
import { getDiscordClient } from '#lib/server/discord';
|
||||||
|
import { refreshMusicCache } from '#lib/server/musicCache';
|
||||||
|
|
||||||
export const init: ServerInit = () => {
|
export const init: ServerInit = () => {
|
||||||
getDiscordClient();
|
getDiscordClient();
|
||||||
|
refreshMusicCache();
|
||||||
};
|
};
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
{#if track}
|
{#if track}
|
||||||
{#key musicPlayer.streamKey}
|
{#key musicPlayer.streamKey}
|
||||||
<audio
|
<audio
|
||||||
|
bind:this={musicPlayer.audioElement}
|
||||||
src={musicPlayer.streamUrl}
|
src={musicPlayer.streamUrl}
|
||||||
autoplay
|
autoplay
|
||||||
preload="auto"
|
preload="auto"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class MusicPlayer {
|
|||||||
history = $state<number[]>([]);
|
history = $state<number[]>([]);
|
||||||
repeat = $state(false);
|
repeat = $state(false);
|
||||||
|
|
||||||
|
audioElement: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
private failedTrackCount = 0;
|
private failedTrackCount = 0;
|
||||||
private loading = false;
|
private loading = false;
|
||||||
|
|
||||||
@@ -108,7 +110,13 @@ class MusicPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seek(seconds: number) {
|
seek(seconds: number) {
|
||||||
this.startOffset = Math.max(0, Math.min(Math.floor(seconds), this.duration - 1));
|
const targetSeconds = Math.max(0, Math.min(Math.floor(seconds), this.duration - 1));
|
||||||
|
if (this.currentTrack?.cached && this.audioElement) {
|
||||||
|
this.audioElement.currentTime = targetSeconds;
|
||||||
|
this.paused = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.startOffset = targetSeconds;
|
||||||
this.elapsedSinceStart = 0;
|
this.elapsedSinceStart = 0;
|
||||||
this.streamKey += 1;
|
this.streamKey += 1;
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export interface Track {
|
|||||||
artist: string;
|
artist: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
cover: string;
|
cover: string;
|
||||||
|
cached: boolean;
|
||||||
}
|
}
|
||||||
+30
-12
@@ -9,7 +9,7 @@ const mp3Bitrate = '160k';
|
|||||||
|
|
||||||
const youtube = new Ytdl();
|
const youtube = new Ytdl();
|
||||||
|
|
||||||
const playlistVideoIds = playlist.videos.flatMap((video) => {
|
export const playlistVideoIds = playlist.videos.flatMap((video) => {
|
||||||
const videoId = parseVideoId(video);
|
const videoId = parseVideoId(video);
|
||||||
return videoId ? [videoId] : [];
|
return videoId ? [videoId] : [];
|
||||||
});
|
});
|
||||||
@@ -30,7 +30,8 @@ async function getTrack(videoId: string): Promise<Track> {
|
|||||||
title: videoDetails.title,
|
title: videoDetails.title,
|
||||||
artist: videoDetails.author.name,
|
artist: videoDetails.author.name,
|
||||||
durationSeconds: videoDetails.lengthSeconds,
|
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);
|
trackCache.set(videoId, track);
|
||||||
return 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 downloadController = new AbortController();
|
||||||
const youtubeAudio = youtube.download(videoId, {
|
const youtubeAudio = youtube.download(videoId, {
|
||||||
quality: Quality.HighestAudio,
|
quality: Quality.HighestAudio,
|
||||||
@@ -62,17 +69,28 @@ export function streamTrackAsMp3(videoId: string, startSeconds: number): Readabl
|
|||||||
if (ffmpeg.exitCode === null) ffmpeg.kill('SIGKILL');
|
if (ffmpeg.exitCode === null) ffmpeg.kill('SIGKILL');
|
||||||
};
|
};
|
||||||
|
|
||||||
youtubeAudio.on('error', (error) => {
|
const finished = new Promise<void>((resolve, reject) => {
|
||||||
console.error(`Music download failed for ${videoId}:`, error);
|
youtubeAudio.on('error', reject);
|
||||||
stop();
|
ffmpeg.on('error', reject);
|
||||||
});
|
ffmpeg.on('close', (exitCode) => (exitCode === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${exitCode}`))));
|
||||||
ffmpeg.on('error', (error) => {
|
|
||||||
console.error('ffmpeg failed to start:', error);
|
|
||||||
stop();
|
|
||||||
});
|
});
|
||||||
|
finished.catch(stop);
|
||||||
|
|
||||||
ffmpeg.stdin.on('error', stop);
|
ffmpeg.stdin.on('error', stop);
|
||||||
ffmpeg.stdout.on('close', stop);
|
|
||||||
youtubeAudio.pipe(ffmpeg.stdin);
|
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>;
|
||||||
}
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getPlaylistTracks } from '#lib/server/music';
|
import { getPlaylistTracks } from '#lib/server/music';
|
||||||
|
import { isSongCached } from '#lib/server/musicCache';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async () => {
|
export const GET: RequestHandler = async () => {
|
||||||
const tracks = await getPlaylistTracks();
|
const tracks = (await getPlaylistTracks()).map((track) => ({ ...track, cached: isSongCached(track.id) }));
|
||||||
return json(tracks, { headers: { 'Cache-Control': 'no-store' } });
|
return json(tracks, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { isPlaylistVideo, streamTrackAsMp3 } from '#lib/server/music';
|
import { isPlaylistVideo, streamTrackAsMp3 } from '#lib/server/music';
|
||||||
|
import { isSongCached, serveCachedSong } from '#lib/server/musicCache';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = ({ params, url }) => {
|
export const GET: RequestHandler = async ({ params, url, request }) => {
|
||||||
if (!isPlaylistVideo(params.id)) error(404, 'Song not found');
|
if (!isPlaylistVideo(params.id)) error(404, 'Song not found');
|
||||||
|
|
||||||
|
if (isSongCached(params.id)) {
|
||||||
|
try {
|
||||||
|
return await serveCachedSong(params.id, request.headers.get('range'));
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.error(`Cached song unavailable for ${params.id}:`, cacheError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const requestedStart = Number(url.searchParams.get('start') ?? 0);
|
const requestedStart = Number(url.searchParams.get('start') ?? 0);
|
||||||
const startSeconds = Number.isFinite(requestedStart) && requestedStart > 0 ? Math.floor(requestedStart) : 0;
|
const startSeconds = Number.isFinite(requestedStart) && requestedStart > 0 ? Math.floor(requestedStart) : 0;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user