This commit is contained in:
2026-09-13 04:54:24 +02:00
parent 340c825019
commit 33c6d0e76d
15 changed files with 425 additions and 104 deletions
+44 -7
View File
@@ -468,7 +468,13 @@ export default new class queue {
this._lastCoverExtracted = false; // Reset state for this call
if (link && typeof link === 'string' && link.match(/soundcloud/)) {
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] : [];
let cover = (await this.spawn('yt-dlp', [...proxyArgs, '-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
let cover = null;
try {
const ytRes = await this.spawn('yt-dlp', [...proxyArgs, '--get-thumbnail', link], { quiet: true });
cover = ytRes.stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err) {
console.warn(`[QUEUE] yt-dlp thumbnail fetch failed for SoundCloud track (${link}):`, err.message || err);
}
if (cover && !cover.match(/default_avatar/)) {
cover = cover.replace(/-(large|original)\./, '-t500x500.');
try {
@@ -517,13 +523,9 @@ export default new class queue {
}
} catch (_) { }
}
// If no cover art found, use audio.webp as the thumbnail
// If no cover art found, generate audio placeholder matching .sidebar-media-placeholder.audio
if (!coverExtracted) {
const audioFallback = path.join(cfg.paths.s, 'img', 'audio.webp');
await fs.promises.copyFile(audioFallback, tmpFile).catch(async () => {
// If copy fails, fall back to generated placeholder
await this.spawn('magick', ['-size', thumbSpec, 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '40', '-annotate', '0', '♪', tmpFile]).catch(() => {});
});
await this.genAudioPlaceholder(tmpFile, thumbSpec, thumbSize);
}
// Store extraction result for caller
this._lastCoverExtracted = coverExtracted;
@@ -647,6 +649,13 @@ export default new class queue {
// Cleanup temp files
await fs.promises.unlink(tmpFile).catch(() => {});
await fs.promises.unlink(tmpJpg).catch(() => {});
if (mime && mime.startsWith('audio/')) {
try {
await this.genAudioPlaceholder(outPath, thumbSpec, thumbSize);
console.warn(`[QUEUE] Used audio placeholder thumbnail for item ${itemid}`);
return false;
} catch (_) {}
}
// Fallback: copy 404.gif as the thumbnail
const fallback404 = path.join(cfg.paths.s, 'img', '404.gif');
try {
@@ -659,6 +668,34 @@ export default new class queue {
}
};
async genAudioPlaceholder(targetFile, thumbSpec = '512x512', thumbSize = 512) {
const faFont = path.join(cfg.paths.s, 'fa', 'webfonts', 'fa-solid-900.ttf');
const ptSize = String(Math.round(thumbSize * 0.35));
try {
await this.spawn('magick', [
'-size', thumbSpec, 'radial-gradient:#334a15-#1e1e1e',
'(', '+clone', '-font', faFont, '-pointsize', ptSize, '-gravity', 'center', '-fill', 'rgba(153,255,0,0.5)', '-annotate', '0', '\uf001', '-blur', '0x12', ')',
'-composite',
'-font', faFont, '-pointsize', ptSize, '-gravity', 'center', '-fill', '#99ff00', '-annotate', '0', '\uf001',
targetFile
]);
return true;
} catch (_) {
const audioFallback = path.join(cfg.paths.s, 'img', 'audio.webp');
try {
await fs.promises.copyFile(audioFallback, targetFile);
return true;
} catch (copyErr) {
await this.spawn('magick', [
'-size', thumbSpec, 'radial-gradient:#334a15-#1e1e1e',
'-gravity', 'center', '-fill', '#99ff00', '-pointsize', '60', '-annotate', '0', '♪',
targetFile
]).catch(() => {});
return false;
}
}
};
async genBlurredThumbnail(itemid, pending = false) {
let tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
+2
View File
@@ -769,6 +769,7 @@ const f0cklib = {
items.is_pinned,
items.is_oc,
items.xd_score,
items.has_coverart,
${user_id ? db`max(coalesce(uvv.view_count, 0)) as my_views,` : db``}
${user_id ? db`EXISTS (SELECT 1 FROM notifications WHERE user_id = ${user_id} AND item_id = items.id AND is_read = false) as has_notification,` : db`false as has_notification,`}
(case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' else 'NSFL' end) as tag,
@@ -794,6 +795,7 @@ const f0cklib = {
const meta = xdScoreMeta(row.xd_score);
row.xd_tier = meta.tier;
row.xd_label = meta.label;
row.is_audio = !!(row.mime && row.mime.startsWith('audio/'));
}
// Dynamic thumb sizing: applies to the main feed including mime/rating filters.
+28 -1
View File
@@ -189,6 +189,32 @@ export default (router, tpl) => {
- Comments/Sub: ${tAjaxAux - tAjaxFetch}ms
- Render: ${tAjaxRender - tAjaxAux}ms`);
let itemPage = null;
if (query.onara === '1' || req.cookies?.onara === '1' || cfg.onara || query.get_page === '1') {
try {
itemPage = await f0cklib.getItemPage({
targetItemId: data.item?.id || itemid,
targetItemPinned: data.item?.is_pinned,
user: query.user,
tag: query.tag,
hall: query.hall,
userHall: query.userHall,
userHallOwner: query.userHallOwner,
mime: query.mime || (req.cookies.mime || null),
fav: query.fav === 'true',
mode: reqMode,
ratings: ratingsArr,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
session: req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
is_admin: req.session?.admin,
minXdScore: req.session?.min_xd_score || 0,
tagger: query.tagger || null
});
} catch (_) {}
}
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -196,7 +222,8 @@ export default (router, tpl) => {
pagination: paginationHtml,
title: data.title,
id: itemid,
slug: data.item?.slug || null
slug: data.item?.slug || null,
page: itemPage
})
});
});
+15 -13
View File
@@ -575,25 +575,25 @@ export default router => {
// Lightweight page-lookup for Onara grid sync.
// Called async after random navigation — user never waits for this.
group.get(/\/item-page$/, async (req, res) => {
const id = parseInt(req.url.qs.id, 10) || 0;
if (!id) return res.json({ success: false, page: 1 });
const rawId = req.url.qs?.id || req.url.qs?.slug || req.url.qs?.item;
if (!rawId) return res.json({ success: false, page: 1 });
const tag = req.url.qs.tag || null;
const hall = req.url.qs.hall || null;
const userHall = req.url.qs.userHall || null;
const userHallOwner= req.url.qs.userHallOwner|| null;
const user = req.url.qs.user || null;
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const mime = (typeof req.url.qs.mime !== 'undefined') ? (req.url.qs.mime || null) : (cookieMime || null);
const isFav = req.url.qs.fav === 'true';
const isStrict = req.url.qs.strict === '1';
const tag = req.url.qs?.tag || null;
const hall = req.url.qs?.hall || null;
const userHall = req.url.qs?.userHall || null;
const userHallOwner= req.url.qs?.userHallOwner|| null;
const user = req.url.qs?.user || null;
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const mime = (typeof req.url.qs?.mime !== 'undefined') ? (req.url.qs.mime || null) : (cookieMime || null);
const isFav = req.url.qs?.fav === 'true';
const isStrict = req.url.qs?.strict === '1';
const mode = req.mode ?? 0;
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
try {
const page = await f0cklib.getItemPage({
targetItemId: id,
targetItemId: rawId,
user, tag, hall, userHall, userHallOwner, mime,
fav: isFav,
mode,
@@ -602,7 +602,9 @@ export default router => {
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
is_admin: req.session?.admin,
minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0),
tagger: req.url.qs?.tagger || null
});
return res.json({ success: true, page });
} catch (e) {
+9 -1
View File
@@ -824,10 +824,18 @@ export default router => {
try {
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
if (mime.startsWith('audio/') && queue._lastCoverExtracted) {
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
}
await queue.genBlurredThumbnail(itemid, isApprovalRequired);
} catch (err) {
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
const outPath = path.join(tDir, `${itemid}.webp`);
if (mime.startsWith('audio/')) {
await queue.genAudioPlaceholder(outPath).catch(() => {});
} else {
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', outPath]).catch(() => {});
}
}
// Assign rating tag (only if a rating was selected)
+21 -9
View File
@@ -87,17 +87,29 @@ export default (router, tpl) => {
`;
if (items.length > 0) {
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
await fs.mkdir(CACHE_DIR, { recursive: true });
const { execFile } = await import('child_process');
const util = await import('util');
const execFilePromise = util.promisify(execFile);
const inputs = [];
for (const item of items) {
const filePath = path.join(cfg.paths.t, `${item.id}.webp`);
try {
await fs.access(filePath);
inputs.push(`${filePath}[0]`);
} catch {
// Ignore missing thumbnail
}
}
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
if (inputs.length > 0) {
await fs.mkdir(CACHE_DIR, { recursive: true });
const { execFile } = await import('child_process');
const util = await import('util');
const execFilePromise = util.promisify(execFile);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
}
}
} catch (e) {
console.error('[HALL_IMAGE] Error:', e);
+42 -6
View File
@@ -38,14 +38,50 @@ export default (router, tpl) => {
route: /^\/s\//
});
router.static({
dir: cfg.paths.t,
route: /^\/t\//
const getImgMime = (filename) => {
if (filename.endsWith('.webp')) return 'image/webp';
if (filename.endsWith('.png')) return 'image/png';
if (filename.endsWith('.gif')) return 'image/gif';
if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) return 'image/jpeg';
return 'application/octet-stream';
};
router.get(/^\/t\/(?<file>.+)$/, async (req, res) => {
const file = req.params.file;
const filePath = path.join(cfg.paths.t, file);
try {
const stat = await fs.stat(filePath);
if (stat.isFile()) {
const content = await fs.readFile(filePath);
res.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': getImgMime(file),
'Cache-Control': 'public, max-age=3600'
});
return res.end(content);
}
} catch (_) {}
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('404 - file not found.');
});
router.static({
dir: cfg.paths.ca,
route: /^\/ca\//
router.get(/^\/ca\/(?<file>.+)$/, async (req, res) => {
const file = req.params.file;
const filePath = path.join(cfg.paths.ca, file);
try {
const stat = await fs.stat(filePath);
if (stat.isFile()) {
const content = await fs.readFile(filePath);
res.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': getImgMime(file),
'Cache-Control': 'public, max-age=3600'
});
return res.end(content);
}
} catch (_) {}
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('404 - file not found.');
});
router.static({
+15 -4
View File
@@ -40,11 +40,22 @@ export async function regenerateTagImage(tag, mode) {
`;
if (items.length > 0) {
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
const inputs = [];
for (const item of items) {
const filePath = path.join(cfg.paths.t, `${item.id}.webp`);
try {
await fs.access(filePath);
inputs.push(`${filePath}[0]`);
} catch {
// Ignore missing thumbnail
}
}
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
return cachePath;
if (inputs.length > 0) {
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
return cachePath;
}
}
} catch (err) {
console.error(`[TAG_IMAGE] Failed to generate image for tag "${tag}" (mode ${mode}):`, err);
+20 -8
View File
@@ -295,14 +295,26 @@ export default (router, tpl) => {
`;
if (items.length > 0) {
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
await fs.mkdir(CACHE_DIR, { recursive: true });
await execFile('magick', [
...inputs, '+append', '-background', 'none',
'-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath
]);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
const inputs = [];
for (const item of items) {
const filePath = path.join(cfg.paths.t, `${item.id}.webp`);
try {
await fs.access(filePath);
inputs.push(`${filePath}[0]`);
} catch {
// Ignore missing thumbnail
}
}
if (inputs.length > 0) {
await fs.mkdir(CACHE_DIR, { recursive: true });
await execFile('magick', [
...inputs, '+append', '-background', 'none',
'-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath
]);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
}
}
} catch (e) {
console.error('[USER_HALL_IMAGE]', e);