diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 75d8f35..4303c4d 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -3164,6 +3164,18 @@ body.sidebar-right-hidden #sidebar-drag-zone { display: none !important; } +.thumb > .sidebar-media-placeholder { + z-index: 1; + font-size: 2.8em; + border-radius: inherit; +} + +.thumb:has(.sidebar-media-placeholder), +.thumb.thumb-fallback:has(.sidebar-media-placeholder) { + opacity: 1 !important; + background-image: none !important; +} + .sidebar-video-details { display: flex; margin-top: 8px; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index a389be6..b21edaf 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -297,7 +297,7 @@ window.cancelAnimFrame = (function () { * Forcefully refreshes all thumbnail occurrences for a specific item in the DOM. * Handles grid items (data-bg), images (src), and the background canvas. */ - window.refreshItemThumbnails = (itemId, timestamp = Date.now()) => { + window.refreshItemThumbnails = (itemId, timestamp = Date.now(), hasCoverart = undefined) => { if (!itemId) return; const idStr = String(itemId); @@ -340,15 +340,28 @@ window.cancelAnimFrame = (function () { // Update audio cover in player if viewing this audio item const audioCover = document.getElementById('f0ck-audio-cover'); if (audioCover && currentId === idStr) { - const coverUrl = `/ca/${idStr}.webp?t=${timestamp}`; - audioCover.src = coverUrl; - const parent = audioCover.parentElement; - if (parent) { - parent.style.background = `url('${coverUrl}') no-repeat center / contain black`; - } - const audioEl = document.querySelector('audio#my-video'); - if (audioEl) { - audioEl.setAttribute('poster', coverUrl); + if (hasCoverart === true || (hasCoverart === undefined && audioCover.src && audioCover.src.includes('/ca/'))) { + const coverUrl = `/ca/${idStr}.webp?t=${timestamp}`; + audioCover.src = coverUrl; + const parent = audioCover.parentElement; + if (parent) { + parent.style.background = `url('${coverUrl}') no-repeat center / contain black`; + } + const audioEl = document.querySelector('audio#my-video'); + if (audioEl) { + audioEl.setAttribute('poster', coverUrl); + } + } else if (hasCoverart === false) { + const fallbackUrl = '/s/img/audio.webp'; + audioCover.src = fallbackUrl; + const parent = audioCover.parentElement; + if (parent) { + parent.style.background = `url('${fallbackUrl}') no-repeat center / contain black`; + } + const audioEl = document.querySelector('audio#my-video'); + if (audioEl) { + audioEl.setAttribute('poster', fallbackUrl); + } } } @@ -434,7 +447,19 @@ window.cancelAnimFrame = (function () { applyThumb(thumb, finalBg); } else { img.onload = () => applyThumb(thumb, finalBg); - img.onerror = () => thumb.classList.remove('lazy-thumb'); + img.onerror = () => { + const mime = thumb.dataset.mime || ''; + if (mime.startsWith('audio/')) { + if (!thumb.querySelector('.sidebar-media-placeholder.audio')) { + const ph = document.createElement('div'); + ph.className = 'sidebar-media-placeholder audio'; + ph.innerHTML = ''; + thumb.prepend(ph); + } + thumb.classList.add('thumb-fallback'); + } + thumb.classList.remove('lazy-thumb'); + }; } thumb.dataset.lazyObserved = 'true'; } @@ -463,7 +488,12 @@ window.cancelAnimFrame = (function () { } else { const mime = thumb.dataset.mime || ''; if (mime.startsWith('audio/')) { - thumb.style.setProperty('--thumb-bg', `url('/s/img/audio.webp')`); + if (!thumb.querySelector('.sidebar-media-placeholder.audio')) { + const ph = document.createElement('div'); + ph.className = 'sidebar-media-placeholder audio'; + ph.innerHTML = ''; + thumb.prepend(ph); + } thumb.classList.add('thumb-fallback'); } thumb.classList.remove('lazy-thumb'); @@ -1346,8 +1376,8 @@ window.cancelAnimFrame = (function () { }; window.openOnaraModal = openOnaraModal; - const updateOnaraActiveItem = (itemid, url) => { - if (!isOnaraActive()) return; + const updateOnaraActiveItem = (itemid, url, forceScroll = false, slug = null) => { + if (!isOnaraActive()) return null; // Clear onara-active from any and all elements to guarantee only 1 item is selected document.querySelectorAll('.onara-active').forEach(el => el.classList.remove('onara-active')); @@ -1358,6 +1388,9 @@ window.cancelAnimFrame = (function () { targetThumb = document.querySelector(`.posts > a.thumb[href="${parsedPath}"], .posts > a.thumb[href="${url}"]`); } catch {} } + if (!targetThumb && slug) { + targetThumb = document.querySelector(`.posts > a.thumb[href$="/${slug}"]`); + } if (!targetThumb && itemid) { targetThumb = document.querySelector(`.posts > a.thumb[href$="/${itemid}"], .posts > a.thumb[data-bg*="/${itemid}."]`); } @@ -1374,13 +1407,140 @@ window.cancelAnimFrame = (function () { const vh = window.innerHeight || document.documentElement.clientHeight; // If any part of the thumbnail is already visible in the viewport, do not scroll const isVisible = rect.bottom > navbarH && rect.top < vh; - if (!isVisible) { - targetThumb.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' }); + if (!isVisible || forceScroll) { + targetThumb.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); } } + return targetThumb; }; window.updateOnaraActiveItem = updateOnaraActiveItem; + let _onaraSyncSeq = 0; + const syncOnaraBackgroundGrid = async (itemid, url, knownPage = null, slug = null) => { + if (!isOnaraActive()) return; + + // Fast path: if target item is already in current background DOM, just highlight and ensure visibility + const existingThumb = updateOnaraActiveItem(itemid, url, false, slug); + if (existingThumb) { + return; + } + + const currentSeq = ++_onaraSyncSeq; + const urlObj = new URL(url || window.location.href, window.location.origin); + const feedBasePath = urlObj.pathname.replace(/\/+$/, '').replace(/\/(?:\d+|[a-zA-Z0-9_-]{11})$/, '') || '/'; + + let targetPage = (typeof knownPage === 'number' && knownPage >= 1) ? knownPage : null; + + if (!targetPage) { + try { + const pageParams = new URLSearchParams(); + pageParams.set('id', itemid); + if (feedBasePath.startsWith('/tag/')) { + pageParams.set('tag', decodeURIComponent(feedBasePath.replace(/^\/tag\//, ''))); + } else if (feedBasePath.startsWith('/h/')) { + pageParams.set('hall', decodeURIComponent(feedBasePath.replace(/^\/h\//, ''))); + } else if (feedBasePath.match(/^\/user\/([^/]+)\/hall\/([^/]+)/)) { + const m = feedBasePath.match(/^\/user\/([^/]+)\/hall\/([^/]+)/); + pageParams.set('userHallOwner', decodeURIComponent(m[1])); + pageParams.set('userHall', decodeURIComponent(m[2])); + } else if (feedBasePath.match(/^\/user\/([^/]+)\/favs/)) { + const m = feedBasePath.match(/^\/user\/([^/]+)\/favs/); + pageParams.set('user', decodeURIComponent(m[1])); + pageParams.set('fav', 'true'); + } else if (feedBasePath.match(/^\/user\/([^/]+)/)) { + const m = feedBasePath.match(/^\/user\/([^/]+)/); + pageParams.set('user', decodeURIComponent(m[1])); + } else if (feedBasePath === '/favs') { + pageParams.set('fav', 'true'); + } + if (typeof window.activeMode !== 'undefined') { + pageParams.set('mode', window.activeMode); + } + const isStrict = window.f0ckSession?.strict_mode || (localStorage.getItem('search_strict') === 'true') || urlObj.searchParams.get('strict') === '1'; + if (isStrict) { + pageParams.set('strict', '1'); + } + const resp = await fetch(`/api/v2/item-page?${pageParams.toString()}`); + if (resp.ok) { + const pageData = await resp.json(); + if (pageData && pageData.success && pageData.page) { + targetPage = pageData.page; + } + } + } catch (e) { + console.warn('[ONARA] Failed to fetch item-page:', e); + } + } + + if (currentSeq !== _onaraSyncSeq) return; + if (!targetPage || targetPage < 1) targetPage = 1; + + const pagePath = targetPage === 1 ? feedBasePath : (feedBasePath === '/' ? `/p/${targetPage}` : `${feedBasePath}/p/${targetPage}`); + const searchParams = new URLSearchParams(urlObj.search); + const searchStr = searchParams.toString() ? `?${searchParams.toString()}` : ''; + const pageUrl = pagePath + searchStr; + + try { + const pageRes = await fetch(pageUrl, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + if (currentSeq !== _onaraSyncSeq) return; + if (!pageRes.ok) return; + + const html = await pageRes.text(); + if (currentSeq !== _onaraSyncSeq) return; + + const doc = new DOMParser().parseFromString(html, 'text/html'); + const newPosts = doc.querySelector('.posts'); + const oldPosts = document.querySelector('.posts'); + + if (newPosts && oldPosts) { + oldPosts.innerHTML = newPosts.innerHTML; + oldPosts.dataset.currentPage = targetPage; + if (newPosts.dataset.hasMore) { + oldPosts.dataset.hasMore = newPosts.dataset.hasMore; + } + if (typeof window.initLazyLoading === 'function') { + window.initLazyLoading(); + } + } + + const newPag = doc.querySelector('.pagination-wrapper'); + const oldPag = document.querySelector('.pagination-wrapper'); + if (newPag && oldPag) { + oldPag.innerHTML = newPag.innerHTML; + } + + const newTitle = doc.querySelector('.page-title-wrap, .page-title, .user-profile-header, .hall-header'); + const oldTitle = document.querySelector('.page-title-wrap, .page-title, .user-profile-header, .hall-header'); + if (newTitle && oldTitle) { + oldTitle.replaceWith(newTitle); + } + + window._onaraCurrentGridUrl = pageUrl; + window._onaraReturnUrl = pageUrl; + if (doc.title) { + window._onaraReturnTitle = doc.title; + } + + const thumb = updateOnaraActiveItem(itemid, url, true, slug); + if (!thumb && oldPosts) { + const synthThumb = document.createElement('a'); + synthThumb.href = url; + synthThumb.className = 'thumb lazy-thumb onara-active'; + synthThumb.dataset.bg = `/t/${itemid}.webp`; + synthThumb.dataset.size = '1'; + synthThumb.innerHTML = '

'; + oldPosts.prepend(synthThumb); + if (typeof window.initLazyLoading === 'function') window.initLazyLoading(); + synthThumb.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); + } + } catch (err) { + console.error('[ONARA] Failed to sync background grid:', err); + } + }; + window.syncOnaraBackgroundGrid = syncOnaraBackgroundGrid; + const isItemPath = (pathOrUrl) => { try { const p = new URL(pathOrUrl, window.location.origin).pathname; @@ -4436,6 +4596,10 @@ window.cancelAnimFrame = (function () { }); } + if (useOnara) { + params.append('onara', '1'); + } + if (params.toString() !== '') { ajaxUrl += (ajaxUrl.includes('?') ? '&' : '?') + params.toString(); } @@ -4466,7 +4630,8 @@ window.cancelAnimFrame = (function () { onaraMount.innerHTML = ''; } _container = onaraMount; - updateOnaraActiveItem(itemid, url); + updateOnaraActiveItem(itemid, url, false, _cachedItem.slug); + syncOnaraBackgroundGrid(itemid, url, _cachedItem.page, _cachedItem.slug); } else { _container = document.querySelector('#main .container') || (document.getElementById('main')?.classList.contains('item-view') ? document.getElementById('main') : null); const _isStructuralPage = !!document.querySelector('.pagewrapper'); @@ -4597,12 +4762,14 @@ window.cancelAnimFrame = (function () { if (!freshText) return; let freshHtml = freshText; let freshSlug = null; + let freshPage = null; try { const d = JSON.parse(freshText); if (d && typeof d.html === 'string') freshHtml = d.html; if (d && (d.slug || d.item?.slug)) freshSlug = d.slug || d.item?.slug; + if (d && d.page) freshPage = d.page; } catch(_) {} - itemCacheMap.set(_itemCacheKey, { html: freshHtml, slug: freshSlug, ts: Date.now() }); + itemCacheMap.set(_itemCacheKey, { html: freshHtml, slug: freshSlug, page: freshPage, ts: Date.now() }); window.f0ckDebug('[itemCache] Background revalidation complete for', _itemCacheKey); }) .catch(() => {}); @@ -4629,7 +4796,7 @@ window.cancelAnimFrame = (function () { - Total Network: ${(tBody - tStart).toFixed(2)}ms - Content Size: ${(rawText.length / 1024).toFixed(2)} KB`); - let html, paginationHtml, responseSlug = null; + let html, paginationHtml, responseSlug = null, responsePage = null; try { // Optimistically try to parse as JSON first @@ -4643,6 +4810,7 @@ window.cancelAnimFrame = (function () { html = data.html; paginationHtml = data.pagination; responseSlug = data.slug || data.item?.slug || null; + responsePage = data.page || null; } else { html = rawText; } @@ -4653,7 +4821,7 @@ window.cancelAnimFrame = (function () { // ── Store in item cache (stale-while-revalidate) ─────────────────────── if (html && !options.noCacheStore) { - itemCacheMap.set(_itemCacheKey, { html, slug: responseSlug, ts: Date.now() }); + itemCacheMap.set(_itemCacheKey, { html, slug: responseSlug, page: responsePage, ts: Date.now() }); if (itemCacheMap.size > ITEM_CACHE_MAX) { // Evict oldest entry itemCacheMap.delete(itemCacheMap.keys().next().value); @@ -4673,7 +4841,8 @@ window.cancelAnimFrame = (function () { onaraMount.innerHTML = ''; } container = onaraMount; - updateOnaraActiveItem(itemid, url); + updateOnaraActiveItem(itemid, url, false, responseSlug); + syncOnaraBackgroundGrid(itemid, url, responsePage, responseSlug); } else { container = document.querySelector('#main .container') || (document.getElementById('main') && document.getElementById('main').classList.contains('item-view') ? document.getElementById('main') : null); const isStructuralPage = !!document.querySelector('.pagewrapper'); @@ -5121,38 +5290,7 @@ window.cancelAnimFrame = (function () { // Background grid sync — page number already in response, no extra fetch needed if (isOnaraActive() && targetId) { - const postsEl = document.querySelector('.posts[data-current-page]'); - const currentPage = postsEl ? parseInt(postsEl.dataset.currentPage, 10) : 1; - - if (targetPage !== currentPage) { - // Build page URL from current grid base - let gridBase = window._onaraCurrentGridUrl - ? window._onaraCurrentGridUrl.replace(/\/p\/\d+/, '').replace(/\/$/, '') - : window.location.pathname - .replace(/\/[a-zA-Z0-9_-]{11}$/, '') - .replace(/\/\d+$/, '') - .replace(/\/p\/\d+/, '') - .replace(/\/$/, ''); - const pageUrl = targetPage === 1 ? (gridBase || '/') : `${gridBase}/p/${targetPage}`; - - fetch(pageUrl) - .then(r => r.text()) - .then(html => { - const doc = new DOMParser().parseFromString(html, 'text/html'); - const newPosts = doc.querySelector('.posts'); - const oldPosts = document.querySelector('.posts'); - if (newPosts && oldPosts) { - oldPosts.innerHTML = newPosts.innerHTML; - oldPosts.dataset.currentPage = targetPage; - if (typeof window.initLazyLoading === 'function') window.initLazyLoading(); - } - window._onaraCurrentGridUrl = pageUrl; - if (typeof updateOnaraActiveItem === 'function') { - updateOnaraActiveItem(targetId, `/${targetKey}`); - } - }) - .catch(() => {}); - } + syncOnaraBackgroundGrid(targetId, `/${targetKey}`, targetPage, targetKey); } } else if (params.has('tag') || params.has('hall') || params.has('user') || params.has('userHall')) { // Context had no matching items with the active MIME filter — try global random with the same filter @@ -9110,7 +9248,7 @@ class NotificationSystem { } else if (data.type === 'rethumb') { window.f0ckDebug(`[SSE] Rethumb update received for item ${data.data?.item_id}`); if (data.data && data.data.item_id && window.refreshItemThumbnails) { - window.refreshItemThumbnails(data.data.item_id); + window.refreshItemThumbnails(data.data.item_id, Date.now(), data.data.has_coverart); } } else if (data.type === 'new_item') { window.f0ckDebug(`[SSE] New item received:`, data.data); @@ -13838,7 +13976,7 @@ document.addEventListener('click', (e) => { // Cache-bust thumbnails and player media on page const timestamp = Date.now(); if (typeof window.refreshItemThumbnails === 'function') { - window.refreshItemThumbnails(itemId, timestamp); + window.refreshItemThumbnails(itemId, timestamp, data.has_coverart); } else { document.querySelectorAll(`img[src*="/t/${itemId}.webp"]`).forEach(img => { const url = new URL(img.src, window.location.origin); diff --git a/scripts/.regen_state.json b/scripts/.regen_state.json new file mode 100644 index 0000000..5ba1557 --- /dev/null +++ b/scripts/.regen_state.json @@ -0,0 +1,4 @@ +{ + "lastId": 48551, + "timestamp": "2026-09-13T02:54:17.890Z" +} \ No newline at end of file diff --git a/src/inc/queue.mjs b/src/inc/queue.mjs index 11c4cc5..1d95a03 100644 --- a/src/inc/queue.mjs +++ b/src/inc/queue.mjs @@ -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; diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 10a315f..7970fd0 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -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. diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index a0ee773..b6ec0f8 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -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 }) }); }); diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index c713065..eacbbc5 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -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) { diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index a3a2d80..d7ab127 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -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) diff --git a/src/inc/routes/halls.mjs b/src/inc/routes/halls.mjs index 6c761e5..6d26840 100644 --- a/src/inc/routes/halls.mjs +++ b/src/inc/routes/halls.mjs @@ -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); diff --git a/src/inc/routes/static.mjs b/src/inc/routes/static.mjs index b5d2a2a..77f34f1 100644 --- a/src/inc/routes/static.mjs +++ b/src/inc/routes/static.mjs @@ -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\/(?.+)$/, 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\/(?.+)$/, 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({ diff --git a/src/inc/routes/tag_image.mjs b/src/inc/routes/tag_image.mjs index 05ab9bc..ed6014d 100644 --- a/src/inc/routes/tag_image.mjs +++ b/src/inc/routes/tag_image.mjs @@ -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); diff --git a/src/inc/routes/user_halls.mjs b/src/inc/routes/user_halls.mjs index 1cf4f1d..0b6855d 100644 --- a/src/inc/routes/user_halls.mjs +++ b/src/inc/routes/user_halls.mjs @@ -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); diff --git a/views/index-partial.html b/views/index-partial.html index b7e1342..85a30f6 100644 --- a/views/index-partial.html +++ b/views/index-partial.html @@ -4,6 +4,11 @@