Fix album art issue with music files
This commit is contained in:
1 parent
881143cbe8
commit
82137c8ca0
4 files changed
+101
-42
No files matched your search
@@ -1301,6 +1301,10 @@ class GenerateImageConfig:
|
|||||||
cap.release()
|
cap.release()
|
||||||
if ok:
|
if ok:
|
||||||
img = PILImage.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
img = PILImage.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
||||||
|
elif ext in ['.mp3', '.wav', '.flac', '.ogg']:
|
||||||
|
# waveform cover rendered at thumb size so the UI never has to read the tags
|
||||||
|
from toolkit.audio.album_artwork import create_artwork, load_waveform
|
||||||
|
img = create_artwork(load_waveform(media_path), size=300)
|
||||||
if img is None:
|
if img is None:
|
||||||
return False
|
return False
|
||||||
img = img.convert('RGB')
|
img = img.convert('RGB')
|
||||||
|
|||||||
+70
-41
@@ -92,6 +92,9 @@ async function getRoots(forceFresh = false): Promise<Roots> {
|
|||||||
const THUMB_SIZE = 300;
|
const THUMB_SIZE = 300;
|
||||||
const IMAGE_THUMB_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']);
|
const IMAGE_THUMB_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']);
|
||||||
const VIDEO_THUMB_EXTS = new Set(['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv']);
|
const VIDEO_THUMB_EXTS = new Set(['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.m4v', '.flv']);
|
||||||
|
// audio thumbs: the embedded cover (the sampler's waveform art) when the file
|
||||||
|
// has one, else a waveform rendered by ffmpeg in the same amber-on-dark look
|
||||||
|
const AUDIO_THUMB_EXTS = new Set(['.mp3', '.wav', '.flac', '.ogg']);
|
||||||
|
|
||||||
// sharp is not a direct dependency; Next.js vendors it (for next/image), so
|
// sharp is not a direct dependency; Next.js vendors it (for next/image), so
|
||||||
// resolve it out of next's node_modules tree. If that ever fails, image
|
// resolve it out of next's node_modules tree. If that ever fails, image
|
||||||
@@ -142,11 +145,35 @@ async function withThumbGenSlot<T>(fn: () => Promise<T>): Promise<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runFfmpeg(args: string[]): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(ffmpegExe, ['-y', '-loglevel', 'error', ...args], {
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
env: ffmpegEnv,
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr!.on('data', chunk => (stderr += chunk.toString()));
|
||||||
|
const timer = setTimeout(() => child.kill('SIGKILL'), 30_000);
|
||||||
|
child.on('error', (err: NodeJS.ErrnoException) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err.code === 'ENOENT') ffmpegMissing = true;
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
child.on('exit', code => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
code === 0 ? resolve() : reject(new Error(`ffmpeg exited with ${code}: ${stderr.trim()}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQUARE_THUMB_VF = `crop='min(iw,ih)':'min(iw,ih)',scale=${THUMB_SIZE}:${THUMB_SIZE}`;
|
||||||
|
|
||||||
async function generateThumb(sourcePath: string, thumbPath: string): Promise<boolean> {
|
async function generateThumb(sourcePath: string, thumbPath: string): Promise<boolean> {
|
||||||
const ext = path.extname(sourcePath).toLowerCase();
|
const ext = path.extname(sourcePath).toLowerCase();
|
||||||
const isImage = IMAGE_THUMB_EXTS.has(ext);
|
const isImage = IMAGE_THUMB_EXTS.has(ext);
|
||||||
const isVideo = VIDEO_THUMB_EXTS.has(ext);
|
const isVideo = VIDEO_THUMB_EXTS.has(ext);
|
||||||
if ((isImage && !sharp) || (isVideo && ffmpegMissing) || (!isImage && !isVideo)) return false;
|
const isAudio = AUDIO_THUMB_EXTS.has(ext);
|
||||||
|
if ((isImage && !sharp) || ((isVideo || isAudio) && ffmpegMissing) || (!isImage && !isVideo && !isAudio)) return false;
|
||||||
await fs.promises.mkdir(path.dirname(thumbPath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(thumbPath), { recursive: true });
|
||||||
// Write to a per-process tmp name, then atomically rename into place (same
|
// Write to a per-process tmp name, then atomically rename into place (same
|
||||||
// as the Python generator) so a concurrent request never reads a partial
|
// as the Python generator) so a concurrent request never reads a partial
|
||||||
@@ -159,34 +186,23 @@ async function generateThumb(sourcePath: string, thumbPath: string): Promise<boo
|
|||||||
.resize(THUMB_SIZE, THUMB_SIZE, { fit: 'cover' })
|
.resize(THUMB_SIZE, THUMB_SIZE, { fit: 'cover' })
|
||||||
.jpeg({ quality: 90 })
|
.jpeg({ quality: 90 })
|
||||||
.toFile(tmpPath);
|
.toFile(tmpPath);
|
||||||
|
} else if (isVideo) {
|
||||||
|
await runFfmpeg(['-i', sourcePath, '-frames:v', '1', '-vf', SQUARE_THUMB_VF, '-q:v', '2', tmpPath]);
|
||||||
} else {
|
} else {
|
||||||
await new Promise<void>((resolve, reject) => {
|
// embedded cover first (ffmpeg exposes an attached picture as a video stream)
|
||||||
const child = spawn(
|
let done = false;
|
||||||
ffmpegExe,
|
if (ext === '.mp3') {
|
||||||
[
|
try {
|
||||||
'-y',
|
await runFfmpeg(['-i', sourcePath, '-map', '0:v:0', '-frames:v', '1', '-vf', SQUARE_THUMB_VF, '-q:v', '2', tmpPath]);
|
||||||
'-loglevel', 'error',
|
done = true;
|
||||||
'-i', sourcePath,
|
} catch {
|
||||||
'-frames:v', '1',
|
// no cover: fall through to the rendered waveform
|
||||||
'-vf', `crop='min(iw,ih)':'min(iw,ih)',scale=${THUMB_SIZE}:${THUMB_SIZE}`,
|
}
|
||||||
'-q:v', '2',
|
}
|
||||||
tmpPath,
|
if (!done) {
|
||||||
],
|
const wave = `color=c=0x1a1a1a:s=${THUMB_SIZE}x${THUMB_SIZE}[bg];[0:a]showwavespic=s=${THUMB_SIZE}x${THUMB_SIZE}:colors=0xfbbf24@0.9[w];[bg][w]overlay=format=auto`;
|
||||||
{ stdio: ['ignore', 'ignore', 'pipe'], env: ffmpegEnv },
|
await runFfmpeg(['-i', sourcePath, '-filter_complex', wave, '-frames:v', '1', '-q:v', '2', tmpPath]);
|
||||||
);
|
}
|
||||||
let stderr = '';
|
|
||||||
child.stderr!.on('data', chunk => (stderr += chunk.toString()));
|
|
||||||
const timer = setTimeout(() => child.kill('SIGKILL'), 30_000);
|
|
||||||
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
if (err.code === 'ENOENT') ffmpegMissing = true;
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
child.on('exit', code => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
code === 0 ? resolve() : reject(new Error(`ffmpeg exited with ${code}: ${stderr.trim()}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
await fs.promises.rename(tmpPath, thumbPath);
|
await fs.promises.rename(tmpPath, thumbPath);
|
||||||
return true;
|
return true;
|
||||||
@@ -241,11 +257,17 @@ const contentTypeMap: { [key: string]: string } = {
|
|||||||
'.ogg': 'audio/ogg',
|
'.ogg': 'audio/ogg',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, prefix: string): Promise<void> {
|
// Returns false only for /api/audio/art/ when no thumb exists and none can be
|
||||||
|
// generated; the caller then proxies to the Next.js route, which extracts the
|
||||||
|
// cover from the file's tags.
|
||||||
|
async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, prefix: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// /api/img/ serves media inline with ETag revalidation; /api/files/ is a
|
// /api/img/ serves media inline with ETag revalidation; /api/files/ is a
|
||||||
// forced download. Both mirror their Next.js route's exact semantics.
|
// forced download. Both mirror their Next.js route's exact semantics.
|
||||||
const isImg = prefix === '/api/img/';
|
// /api/audio/art/ is the audio file's thumb (embedded cover / waveform),
|
||||||
|
// served inline like an image.
|
||||||
|
const isArt = prefix === '/api/audio/art/';
|
||||||
|
const isImg = prefix === '/api/img/' || isArt;
|
||||||
const urlPath = (req.url || '').split('?')[0];
|
const urlPath = (req.url || '').split('?')[0];
|
||||||
const rest = urlPath.slice(prefix.length);
|
const rest = urlPath.slice(prefix.length);
|
||||||
// Decode per URL segment so both forms resolve to the same file:
|
// Decode per URL segment so both forms resolve to the same file:
|
||||||
@@ -280,14 +302,15 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr
|
|||||||
console.warn(`Access denied: ${resolvedFilePath} not in ${allowedDirs.join(', ')}`);
|
console.warn(`Access denied: ${resolvedFilePath} not in ${allowedDirs.join(', ')}`);
|
||||||
res.writeHead(403);
|
res.writeHead(403);
|
||||||
res.end('Access denied');
|
res.end('Access denied');
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ?thumb=1 serves the 300x300 jpg from the sibling .thumbs folder
|
// ?thumb=1 serves the 300x300 jpg from the sibling .thumbs folder
|
||||||
// (<name>.<ext>.jpg), generating and saving it on the fly when missing.
|
// (<name>.<ext>.jpg), generating and saving it on the fly when missing.
|
||||||
// Falls through to the full file only if generation isn't possible
|
// Falls through to the full file only if generation isn't possible
|
||||||
// (unsupported format, no ffmpeg, corrupt file).
|
// (unsupported format, no ffmpeg, corrupt file). Album art is always the
|
||||||
if (isImg && new URL(req.url || '', 'http://localhost').searchParams.has('thumb')) {
|
// thumb.
|
||||||
|
if (isArt || (isImg && new URL(req.url || '', 'http://localhost').searchParams.has('thumb'))) {
|
||||||
const thumbPath = path.join(path.dirname(resolvedFilePath), '.thumbs', path.basename(resolvedFilePath) + '.jpg');
|
const thumbPath = path.join(path.dirname(resolvedFilePath), '.thumbs', path.basename(resolvedFilePath) + '.jpg');
|
||||||
let thumbStat = await fs.promises.stat(thumbPath).catch(() => null);
|
let thumbStat = await fs.promises.stat(thumbPath).catch(() => null);
|
||||||
if (!(thumbStat && thumbStat.isFile())) {
|
if (!(thumbStat && thumbStat.isFile())) {
|
||||||
@@ -298,6 +321,8 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr
|
|||||||
}
|
}
|
||||||
if (thumbStat && thumbStat.isFile()) {
|
if (thumbStat && thumbStat.isFile()) {
|
||||||
resolvedFilePath = thumbPath;
|
resolvedFilePath = thumbPath;
|
||||||
|
} else if (isArt) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,12 +332,12 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr
|
|||||||
} catch {
|
} catch {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end('File not found');
|
res.end('File not found');
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (!stat.isFile()) {
|
if (!stat.isFile()) {
|
||||||
res.writeHead(400);
|
res.writeHead(400);
|
||||||
res.end('Not a file');
|
res.end('Not a file');
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = path.basename(resolvedFilePath);
|
const filename = path.basename(resolvedFilePath);
|
||||||
@@ -332,7 +357,7 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr
|
|||||||
if (req.headers['if-none-match'] === etag) {
|
if (req.headers['if-none-match'] === etag) {
|
||||||
res.writeHead(304, { ETag: etag, 'Cache-Control': headers['Cache-Control'] });
|
res.writeHead(304, { ETag: etag, 'Cache-Control': headers['Cache-Control'] });
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
headers['Cache-Control'] = 'public, max-age=86400';
|
headers['Cache-Control'] = 'public, max-age=86400';
|
||||||
@@ -354,7 +379,7 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr
|
|||||||
if (isNaN(start) || isNaN(end) || start > end || start >= stat.size) {
|
if (isNaN(start) || isNaN(end) || start > end || start >= stat.size) {
|
||||||
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
|
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
status = 206;
|
status = 206;
|
||||||
headers['Content-Range'] = `bytes ${start}-${end}/${stat.size}`;
|
headers['Content-Range'] = `bytes ${start}-${end}/${stat.size}`;
|
||||||
@@ -364,12 +389,12 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr
|
|||||||
res.writeHead(status, headers);
|
res.writeHead(status, headers);
|
||||||
if (req.method === 'HEAD') {
|
if (req.method === 'HEAD') {
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (res.destroyed) {
|
if (res.destroyed) {
|
||||||
// Client disconnected while we were stat-ing; piping into a destroyed
|
// Client disconnected while we were stat-ing; piping into a destroyed
|
||||||
// stream throws.
|
// stream throws.
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileStream = fs.createReadStream(resolvedFilePath, {
|
const fileStream = fs.createReadStream(resolvedFilePath, {
|
||||||
@@ -382,10 +407,12 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr
|
|||||||
// is gone.
|
// is gone.
|
||||||
if (err) res.destroy();
|
if (err) res.destroy();
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error serving file:', error);
|
console.error('Error serving file:', error);
|
||||||
if (!res.headersSent) res.writeHead(500);
|
if (!res.headersSent) res.writeHead(500);
|
||||||
res.end();
|
res.end();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,9 +505,11 @@ function startServer(publicPort: number, upstreamPort: number): void {
|
|||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const urlPath = (req.url || '').split('?')[0];
|
const urlPath = (req.url || '').split('?')[0];
|
||||||
if (req.method === 'GET' || req.method === 'HEAD') {
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
||||||
const prefix = ['/api/files/', '/api/img/'].find(p => urlPath.startsWith(p));
|
const prefix = ['/api/files/', '/api/img/', '/api/audio/art/'].find(p => urlPath.startsWith(p));
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
serveFile(req, res, prefix);
|
serveFile(req, res, prefix).then(handled => {
|
||||||
|
if (!handled) proxy(req, res, upstreamPort);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,21 @@ export async function GET(request: NextRequest, { params }: { params: { audioPat
|
|||||||
return new NextResponse('File not found', { status: 404 });
|
return new NextResponse('File not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pre-generated thumb (written by the sampler, or by a previous call here)
|
||||||
|
// so the tags are read at most once per file
|
||||||
|
const thumbPath = path.join(path.dirname(resolved), '.thumbs', path.basename(resolved) + '.jpg');
|
||||||
|
const thumbStat = await fs.promises.stat(thumbPath).catch(() => null);
|
||||||
|
if (thumbStat && thumbStat.isFile()) {
|
||||||
|
const data = await fs.promises.readFile(thumbPath);
|
||||||
|
return new NextResponse(data as any, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
'Content-Length': String(data.length),
|
||||||
|
'Cache-Control': 'public, max-age=604800, immutable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Read only the ID3 tag (first min(tagSize, 4MB) bytes).
|
// Read only the ID3 tag (first min(tagSize, 4MB) bytes).
|
||||||
// First read 10 bytes to get tag size, then read the full tag.
|
// First read 10 bytes to get tag size, then read the full tag.
|
||||||
const fd = await fs.promises.open(resolved, 'r');
|
const fd = await fs.promises.open(resolved, 'r');
|
||||||
@@ -173,6 +188,16 @@ export async function GET(request: NextRequest, { params }: { params: { audioPat
|
|||||||
return new NextResponse('No album art found', { status: 404 });
|
return new NextResponse('No album art found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (art.mime === 'image/jpeg') {
|
||||||
|
// cache as the file's thumb; best effort, the response does not depend on it
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(path.dirname(thumbPath), { recursive: true });
|
||||||
|
await fs.promises.writeFile(thumbPath, art.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('album art thumb not written:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new NextResponse(art.data as any, {
|
return new NextResponse(art.data as any, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': art.mime,
|
'Content-Type': art.mime,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { NextResponse } from 'next/server';
|
|||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
// if route starts with these, approve
|
// if route starts with these, approve
|
||||||
const publicRoutes = ['/api/img/', '/api/files/'];
|
// album art is fetched by plain <img> tags, which cannot send the bearer token
|
||||||
|
const publicRoutes = ['/api/img/', '/api/files/', '/api/audio/art/'];
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
// check env var for AI_TOOLKIT_AUTH, if not set, approve all requests
|
// check env var for AI_TOOLKIT_AUTH, if not set, approve all requests
|
||||||
|
|||||||
Reference in new issue
Block a user