From 2913b03d506b0cf84150fa4fdd3afa91ed9a2ba0 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Sat, 12 Sep 2026 04:25:49 +0200 Subject: [PATCH] f0ck algos --- .../add_user_recommendation_affinity.sql | 25 + public/s/css/f0ckm.css | 454 ++++++++++++++++- public/s/js/f0ckm.js | 203 +++++++- public/s/js/sidebar-activity.js | 467 +++++++++++++++++ src/inc/locales/de.json | 8 +- src/inc/locales/en.json | 9 +- src/inc/locales/nl.json | 8 +- src/inc/locales/zange.json | 8 +- src/inc/routeinc/f0cklib.mjs | 473 +++++++++++++++++- src/inc/routes/apiv2/index.mjs | 99 ++++ views/snippets/footer.html | 20 +- views/snippets/info-modal.html | 4 +- 12 files changed, 1756 insertions(+), 22 deletions(-) create mode 100644 migrations/add_user_recommendation_affinity.sql diff --git a/migrations/add_user_recommendation_affinity.sql b/migrations/add_user_recommendation_affinity.sql new file mode 100644 index 0000000..4d9fa4a --- /dev/null +++ b/migrations/add_user_recommendation_affinity.sql @@ -0,0 +1,25 @@ +-- User Interest & Behavior Recommendation Affinity Tables + +CREATE TABLE IF NOT EXISTS public.user_tag_affinity ( + user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + tag_id integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + score real DEFAULT 0.0, + interaction_count integer DEFAULT 1, + last_interacted timestamp with time zone DEFAULT now(), + PRIMARY KEY (user_id, tag_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_tag_affinity_user_score +ON public.user_tag_affinity(user_id, score DESC); + +CREATE TABLE IF NOT EXISTS public.user_creator_affinity ( + user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + creator_username text NOT NULL, + score real DEFAULT 0.0, + interaction_count integer DEFAULT 1, + last_interacted timestamp with time zone DEFAULT now(), + PRIMARY KEY (user_id, creator_username) +); + +CREATE INDEX IF NOT EXISTS idx_user_creator_affinity_user_score +ON public.user_creator_affinity(user_id, score DESC); diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 52d0b79..89aedb5 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -2229,7 +2229,7 @@ html[theme='95'] .item-main-content .metadata { width: 320px; background: var(--dropdown-bg); border: 1px solid var(--nav-border-color); - z-index: 1000; + z-index: 10000 !important; } /* Arrow caret pointing up toward the icon */ @@ -2585,6 +2585,7 @@ body.layout-modern .global-sidebar-right { display: flex; align-items: center; justify-content: center; + top: var(--navbar-h, 50px) !important; opacity: 0; transition: opacity 0.2s ease; } @@ -2625,6 +2626,7 @@ body.sidebar-right-hidden #sidebar-drag-zone { display: flex; align-items: center; justify-content: flex-start; + top: var(--navbar-h, 50px) !important; padding-left: 10px; opacity: 0; transition: opacity 0.2s ease; @@ -2918,6 +2920,432 @@ body.sidebar-right-hidden #sidebar-drag-zone { letter-spacing: 1px; } +/* Sidebar Tabs */ +.sidebar-tabs { + display: flex; + width: 100%; + background: var(--nav-bg); + border-bottom: 1px solid var(--nav-border-color); + user-select: none; + flex-shrink: 0; + z-index: 2; +} + +.sidebar-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 11px 0; + background: transparent; + border: none; + outline: none; + cursor: pointer; + color: var(--text-color, #888); + opacity: 0.55; + font-size: 1.15em; + position: relative; + transition: color 0.2s ease, opacity 0.2s ease, background-color 0.2s ease; +} + +.sidebar-tab:hover { + color: var(--accent); + opacity: 0.9; + background: rgba(255, 255, 255, 0.03); +} + +.sidebar-tab.active { + color: var(--accent); + opacity: 1; +} + +.sidebar-tab::after { + content: ''; + position: absolute; + bottom: -1px; + left: 20%; + right: 20%; + height: 2px; + background: transparent; + transition: background-color 0.2s ease; + border-radius: 2px 2px 0 0; +} + +.sidebar-tab.active::after { + background: var(--accent); +} + +/* Sidebar Tab Content panels */ +.sidebar-tab-content { + display: none; + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: none; + touch-action: pan-y; +} + +.sidebar-tab-content::-webkit-scrollbar { + display: none; +} + +.sidebar-tab-content.active { + display: block; +} + +/* Video Recommendations List */ +.sidebar-recommendations-list { + flex: 1; + overflow-y: auto; + scrollbar-width: none; + padding: 6px; + touch-action: pan-y; +} + +.sidebar-recommendations-list::-webkit-scrollbar { + display: none; +} + +/* Recommendation Video Cards (YouTube-style) */ +.sidebar-video-card { + margin-bottom: 5px; + border-radius: 8px; + padding: 5px; + overflow: hidden; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease; +} + +.sidebar-video-card:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.12); + transform: translateY(-1px); +} + +.sidebar-video-card:active { + transform: scale(0.99); +} + +.sidebar-video-card.sidebar-card-swapping { + opacity: 0.35; + filter: grayscale(0.6); + transition: opacity 0.5s ease, filter 0.5s ease; +} + +.sidebar-video-card.sidebar-card-entering { + animation: sidebarCardEnter 2s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes sidebarCardEnter { + 0% { + opacity: 0; + transform: scale(0.91) translateY(8px); + filter: brightness(1.2) contrast(0.95); + } + 40% { + opacity: 0.75; + transform: scale(0.97) translateY(3px); + } + 75% { + opacity: 0.95; + transform: scale(0.995) translateY(0.5px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + filter: brightness(1) contrast(1); + } +} + +.sidebar-video-card .sidebar-video-link { + display: flex; + flex-direction: column; + text-decoration: none; + color: inherit; +} + +.sidebar-video-thumb-wrap { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 6px; + overflow: hidden; + background: #000; +} + +.sidebar-video-thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.3s ease; +} + +.sidebar-video-card:hover .sidebar-video-thumb { + transform: scale(1.04); +} + +.sidebar-video-play-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.25); + color: #fff; + font-size: 1.2em; + opacity: 0; + transition: opacity 0.2s ease, background 0.2s ease; +} + +.sidebar-video-play-overlay i { + width: 38px; + height: 38px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + padding-left: 3px; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); +} + +.sidebar-video-card:hover .sidebar-video-play-overlay { + opacity: 1; + background: rgba(0, 0, 0, 0.35); +} + +.sidebar-video-badge { + position: absolute; + bottom: 6px; + right: 6px; + font-size: 0.65em; + font-weight: 700; + padding: 2px 5px; + border-radius: 3px; + text-transform: uppercase; + line-height: 1; + letter-spacing: 0.5px; + color: #fff; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +.sidebar-video-badge.rating-sfw { + background-color: var(--badge-sfw); + border: 1px solid rgba(0, 0, 0, 0.2); +} + +.sidebar-video-badge.rating-nsfw { + background-color: var(--badge-nsfw); + border: 1px solid rgba(0, 0, 0, 0.2); +} + +.sidebar-video-badge.rating-nsfl { + background-color: var(--badge-nsfl); + border: 1px solid rgba(0, 0, 0, 0.2); +} + +.sidebar-video-badge.rating-untagged { + background-color: var(--badge-tag, #37474f); + border: 1px dashed rgba(255, 255, 255, 0.35); +} + +.sidebar-media-format-badge { + position: absolute; + top: 6px; + left: 6px; + font-size: 0.6em; + font-weight: 700; + padding: 2px 5px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + letter-spacing: 0.5px; + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.15); + line-height: 1; +} + +.sidebar-media-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #1e1b4b, #312e81); + color: var(--accent); + font-size: 2em; +} + +.sidebar-media-placeholder.audio { + background: linear-gradient(135deg, #0f172a, #1e293b); + color: #38bdf8; +} + +.sidebar-media-placeholder.hidden { + display: none !important; +} + +.sidebar-video-details { + display: flex; + margin-top: 8px; +} + +.sidebar-video-info { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.sidebar-video-title { + font-size: 0.86em; + font-weight: 600; + color: var(--text-color, #eee); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + transition: color 0.15s ease; +} + +.sidebar-video-card:hover .sidebar-video-title { + color: var(--accent); +} + +.sidebar-video-channel { + font-size: 0.76em; + color: #999; + margin-top: 3px; + line-height: 1.2; +} + +.sidebar-video-user { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + max-width: 100%; +} + +.sidebar-video-meta { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.7em; + color: #777; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-video-time { + opacity: 0.85; +} + +.sidebar-video-xd { + font-size: 0.8em; + font-weight: bold; + padding: 0 4px; + border-radius: 2px; + display: inline-block; + background: rgba(255, 255, 255, 0.08); + color: var(--accent); +} + +.sidebar-personalized-pill { + display: inline-flex; + align-items: center; + gap: 3px; + color: var(--accent, #a78bfa); + font-weight: 600; + letter-spacing: 0.02em; +} + +.sidebar-personalized-pill i { + font-size: 0.9em; +} + +/* Light / Paper themes */ +html[theme='light'] .sidebar-tabs, +html[theme='paper'] .sidebar-tabs { + background: #ffffff; + border-bottom: 1px solid #e2e8f0; +} + +html[theme='light'] .sidebar-tab, +html[theme='paper'] .sidebar-tab { + color: #64748b; +} + +html[theme='light'] .sidebar-tab:hover, +html[theme='paper'] .sidebar-tab:hover { + background: #f8fafc; +} + +html[theme='light'] .sidebar-video-card, +html[theme='paper'] .sidebar-video-card { + background: #ffffff; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} + +html[theme='light'] .sidebar-video-card:hover, +html[theme='paper'] .sidebar-video-card:hover { + background: #f8fafc; + border-color: #cbd5e1; +} + +html[theme='light'] .sidebar-video-title, +html[theme='paper'] .sidebar-video-title { + color: #0f172a; +} + +html[theme='light'] .sidebar-video-channel, +html[theme='paper'] .sidebar-video-channel { + color: #64748b; +} + +html[theme='light'] .sidebar-video-play-overlay, +html[theme='paper'] .sidebar-video-play-overlay { + background: rgba(0, 0, 0, 0.2); +} + +/* Theme 95 */ +html[theme='95'] .sidebar-tabs { + background: #c0c0c0; + border-bottom: 2px solid #808080; +} + +html[theme='95'] .sidebar-tab { + border-right: 1px solid #808080; + color: #000; +} + +html[theme='95'] .sidebar-tab.active { + background: #dfdfdf; + box-shadow: inset 1px 1px 0 #fff, inset -1px -1px 0 #808080; +} + +html[theme='95'] .sidebar-video-card { + background: #c0c0c0; + border: 2px solid #808080; + border-right-color: #fff; + border-bottom-color: #fff; +} + +html[theme='95'] .sidebar-video-title { + color: #000; + font-family: 'MS Sans Serif', sans-serif; +} + #sidebar-activity-container.sidebar-comments-list { flex: 1; overflow-y: auto; @@ -9735,7 +10163,7 @@ input#s_avatar { background: var(--dropdown-bg); border: 1px solid var(--nav-border-color); border-radius: 0; - z-index: 10000; + z-index: 10000 !important; overflow: hidden; } @@ -11010,10 +11438,6 @@ video.sidebar-comment-img.emoji { margin-right: 4px; } -.sidebar-video-link:hover { - text-decoration: underline; -} - .comment-content img:not(.emoji) { display: block; cursor: pointer; @@ -11522,7 +11946,7 @@ div.sbt { body > nav.navbar { position: sticky !important; top: 0 !important; - z-index: 1005 !important; + z-index: 10070 !important; width: 100% !important; display: flex !important; flex-direction: row !important; @@ -11648,17 +12072,22 @@ body > nav.navbar { } /* Right-anchor all dropdowns inside nav-right-group */ -.nav-right-group .nav-user-menu { +.nav-right-group .nav-user-menu, +#nav-user-menu, +#nav-visitor-menu { left: auto; right: 0; transform: none; + z-index: 10000 !important; } -.nav-right-group .notif-dropdown { +.nav-right-group .notif-dropdown, +#notif-dropdown { left: auto; right: 0; /* anchor to bell container right edge; JS overrides on mobile */ transform: none; + z-index: 10000 !important; } /* Shift arrow to sit directly under the bell icon */ @@ -19075,7 +19504,7 @@ body.onara-modal-open nav.navbar { opacity: 1 !important; filter: none !important; pointer-events: auto !important; - z-index: 10060 !important; + z-index: 10070 !important; position: fixed !important; top: 0 !important; left: 0 !important; @@ -19084,7 +19513,7 @@ body.onara-modal-open nav.navbar { /* Ensure hamburger dropdown is interactive and above the Onara modal */ body.onara-modal-open nav.navbar .nav-collapse { - z-index: 10065 !important; + z-index: 10075 !important; pointer-events: auto !important; } @@ -19098,7 +19527,8 @@ body.onara-modal-open .global-sidebar-right { body.onara-modal-open #sidebar-drag-zone { opacity: 1 !important; pointer-events: auto !important; - z-index: 10061 !important; + z-index: 10059 !important; + top: var(--navbar-h, 50px) !important; } body.onara-modal-open .admin-bar { diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 43fa3ba..b9048af 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -4739,6 +4739,7 @@ window.cancelAnimFrame = (function () { // Special check for random if (link.id === 'random' || link.id === 'nav-random') { e.preventDefault(); + document.dispatchEvent(new CustomEvent('f0ck:randomTriggered')); const outEls = document.querySelectorAll('.content, ._204863, .item_title'); outEls.forEach(el => { @@ -4970,9 +4971,9 @@ window.cancelAnimFrame = (function () { const parts = pathname.split('/').filter(Boolean); const isItemLink = isItemPath(pathname); if (isItemLink) { - // Links inside comment bodies or MOTD should not inherit tag/hall/user context - const _inComment = anyLink.closest(".comment, .comment-content, .comment-body, .motd-content"); - loadItemAjax(targetUrl, !_inComment); + // Links inside comment bodies, MOTD, or sidebar suggestions/recommendations should not inherit tag/hall/user context + const _skipInherit = anyLink.dataset.inheritContext === 'false' || anyLink.closest(".comment, .comment-content, .comment-body, .motd-content, .sidebar-video-card, .sidebar-recommendations-list, #sidebar-recommendations-container"); + loadItemAjax(targetUrl, !_skipInherit); } else { const isBrand = anyLink.classList.contains('navbar-brand'); const fromModal = document.body.classList.contains('onara-modal-open') || !!anyLink.closest('#onara-modal'); @@ -13192,3 +13193,199 @@ document.addEventListener('keydown', (e) => { } } }); + +// ── User Interest & Behavioral Recommendation Engine (Client Tracking) ── +(function() { + const STORAGE_TAGS = 'f0ck_guest_tag_affinity'; + const STORAGE_CREATORS = 'f0ck_guest_creator_affinity'; + + let currentTrackingItem = null; + + const loadStoredMap = (key) => { + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : {}; + } catch (_) { + return {}; + } + }; + + const saveStoredMap = (key, map) => { + try { + localStorage.setItem(key, JSON.stringify(map)); + } catch (_) {} + }; + + const updateLocalAffinity = (key, name, delta) => { + if (!name || typeof name !== 'string') return; + const cleanName = name.trim().toLowerCase(); + if (!cleanName) return; + + const map = loadStoredMap(key); + const existing = map[cleanName] || { score: 0, count: 0, ts: Date.now() }; + existing.score = Math.max(-10, Math.min(500, (existing.score || 0) + delta)); + existing.count = (existing.count || 0) + 1; + existing.ts = Date.now(); + map[cleanName] = existing; + + // Prune to top 60 to prevent storage bloat + const entries = Object.entries(map); + if (entries.length > 60) { + entries.sort((a, b) => b[1].score - a[1].score); + const pruned = Object.fromEntries(entries.slice(0, 50)); + saveStoredMap(key, pruned); + } else { + saveStoredMap(key, map); + } + }; + + const f0ckInterestEngine = { + getTopAffinities: () => { + const tagsMap = loadStoredMap(STORAGE_TAGS); + const creatorsMap = loadStoredMap(STORAGE_CREATORS); + + const topTags = Object.entries(tagsMap) + .filter(([, v]) => v.score > 0) + .sort((a, b) => b[1].score - a[1].score) + .slice(0, 15) + .map(([k]) => k); + + const topCreators = Object.entries(creatorsMap) + .filter(([, v]) => v.score > 0) + .sort((a, b) => b[1].score - a[1].score) + .slice(0, 10) + .map(([k]) => k); + + return { topTags, topCreators }; + }, + + recordInteraction: ({ itemId, tags = [], creator = '', duration = 0, percent = 0, type = 'dwell' }) => { + if (!itemId) return; + + // Behavioral weighting: time spent is a primary signal + let delta = 1.0; + if (type === 'skip' || (duration < 2.5 && percent < 15)) { + delta = -1.5; + } else if (type === 'click_suggestion') { + delta = 2.5; + } else if (type === 'finish' || percent >= 75 || duration >= 20) { + delta = 3.0; + } else if (duration >= 8 || percent >= 35) { + delta = 1.5; + } + + // Update local storage (for persistent guest learning across browser restarts) + if (Array.isArray(tags)) { + tags.forEach(t => updateLocalAffinity(STORAGE_TAGS, t, delta)); + } + if (creator) { + updateLocalAffinity(STORAGE_CREATORS, creator, delta); + } + + // Send beacon to backend + const payload = { + item_id: itemId, + duration: Math.round(duration * 10) / 10, + percent: Math.round(percent), + type + }; + + try { + if (navigator.sendBeacon) { + const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); + navigator.sendBeacon('/api/v2/track/interaction', blob); + } else { + fetch('/api/v2/track/interaction', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive: true + }).catch(() => {}); + } + } catch (_) {} + }, + + recordSuggestionClick: (itemId, tags = [], creator = '') => { + if (!itemId) return; + f0ckInterestEngine.recordInteraction({ itemId, tags, creator, type: 'click_suggestion' }); + }, + + flushCurrentTracking: () => { + if (!currentTrackingItem) return; + const { itemId, startTime, maxPercent, tags, creator } = currentTrackingItem; + currentTrackingItem = null; + + const duration = (Date.now() - startTime) / 1000; + let type = 'dwell'; + if (duration < 2.5 && maxPercent < 15) { + type = 'skip'; + } else if (maxPercent >= 75 || duration >= 20) { + type = 'finish'; + } + + f0ckInterestEngine.recordInteraction({ + itemId, + tags, + creator, + duration, + percent: maxPercent, + type + }); + }, + + startTrackingItem: () => { + f0ckInterestEngine.flushCurrentTracking(); + + // Only track if on an item page + const itemIdEl = document.querySelector('[data-item-id]'); + const itemId = itemIdEl ? parseInt(itemIdEl.dataset.itemId, 10) : null; + if (!itemId) return; + + const tags = Array.from(document.querySelectorAll('#tags .tag-text, #tags .tag-badge, .tag-link')) + .map(el => el.textContent.trim().replace(/^#/, '')) + .filter(Boolean); + + const creatorEl = document.querySelector('#a_username, [data-username]'); + const creator = creatorEl ? (creatorEl.dataset.username || creatorEl.textContent.trim()) : ''; + + currentTrackingItem = { + itemId, + startTime: Date.now(), + maxPercent: 0, + tags, + creator + }; + + // Attach media playback listeners + const media = document.querySelector('video, audio'); + if (media) { + const updatePercent = () => { + if (!currentTrackingItem || !media.duration) return; + const p = (media.currentTime / media.duration) * 100; + if (p > currentTrackingItem.maxPercent) { + currentTrackingItem.maxPercent = p; + } + }; + + media.addEventListener('timeupdate', updatePercent, { passive: true }); + media.addEventListener('ended', () => { + if (currentTrackingItem) currentTrackingItem.maxPercent = 100; + }, { once: true }); + } + } + }; + + window.f0ckInterestEngine = f0ckInterestEngine; + + // Listen to item content loads and page teardown + document.addEventListener('f0ck:contentLoaded', f0ckInterestEngine.startTrackingItem); + window.addEventListener('beforeunload', f0ckInterestEngine.flushCurrentTracking); + window.addEventListener('pagehide', f0ckInterestEngine.flushCurrentTracking); + + // Initial check on load + if (document.readyState === 'complete' || document.readyState === 'interactive') { + setTimeout(f0ckInterestEngine.startTrackingItem, 200); + } else { + document.addEventListener('DOMContentLoaded', () => setTimeout(f0ckInterestEngine.startTrackingItem, 200)); + } +})(); diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js index 4dcd469..187c936 100644 --- a/public/s/js/sidebar-activity.js +++ b/public/s/js/sidebar-activity.js @@ -1044,6 +1044,13 @@ currentPage = 1; hasMore = true; loadActivity(false); // Force reload with loading state + + recommendationsLoaded = false; + let savedTab = 'comments'; + try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {} + if (savedTab === 'videos') { + loadVideoRecommendations(false); + } } else { // Immediately render from cache to avoid flicker renderFromCache(); @@ -1057,6 +1064,456 @@ syncSidebarAndComments(); } + // Video / Media Recommendations Logic + let recommendationsLoading = false; + let recommendationsLoadingMore = false; + let recommendationsLoaded = false; + let currentRecommendations = []; + const seenRecommendationIds = new Set(); + const RECOMMENDATIONS_LIMIT = SIDEBAR_MAX_COMMENTS; + let recIoSentinel = null; + let recObserver = null; + + const renderVideoCard = (video) => { + const videoKey = (window.f0ckSession?.enable_item_slugs && video.slug) ? video.slug : video.id; + const rClass = video.rating_class || 'untagged'; + const blurNsfw = localStorage.getItem('blurNsfw') === 'true'; + const blurNsfl = localStorage.getItem('blurNsfl') === 'true'; + const blurSfw = localStorage.getItem('blurSfw') === 'true'; + const blurUntagged = localStorage.getItem('blurUntagged') === 'true'; + + let isBlurred = false; + if (rClass === 'nsfw' && blurNsfw) isBlurred = true; + else if (rClass === 'nsfl' && blurNsfl) isBlurred = true; + else if (rClass === 'sfw' && blurSfw) isBlurred = true; + else if (rClass === 'untagged' && blurUntagged) isBlurred = true; + + const mime = video.mime || ''; + const isAudio = mime.startsWith('audio/'); + const isVideo = mime.startsWith('video/'); + const isImage = mime.startsWith('image/'); + + let thumbUrl = `/t/${video.id}.webp`; + if (isBlurred) { + thumbUrl = `/t/${video.id}_blur.webp`; + } + if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl); + + let displayTitle = ''; + if (video.title && video.title.trim()) { + displayTitle = video.title.trim(); + } else if (video.tags && video.tags.length > 0) { + displayTitle = video.tags.map(t => '#' + t).join(' '); + } else { + const typeLabel = isAudio ? 'Audio' : (isVideo ? 'Video' : 'Image'); + displayTitle = `${typeLabel} #${video.id}`; + } + + const isAnonGuest = window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in; + const authorName = isAnonGuest ? 'anonymous' : (video.display_name || video.username); + const userColorStyle = (!isAnonGuest && video.username_color) ? `style="color: ${escapeHtml(video.username_color)}"` : ''; + + const timeStr = video.stamp + ? (window.f0ckTimeAgo ? window.f0ckTimeAgo(new Date(video.stamp * 1000).toISOString()) : '') + : ''; + const fullDate = video.stamp ? new Date(video.stamp * 1000).toLocaleString() : ''; + + const xdBadge = (video.xd_score && video.xd_score > 0) + ? `xD ${video.xd_score}` + : ''; + + const ext = (video.mime ? video.mime.split('/')[1] : '') + .replace('jpeg', 'jpg') + .replace('x-shockwave-flash', 'flash') + .replace('x-flac', 'flac') + .replace('mpeg', 'mp3') + .toUpperCase(); + const formatBadge = ext ? `${escapeHtml(ext)}` : ''; + const overlayIcon = isAudio ? 'fa-solid fa-music' : (isVideo ? 'fa-solid fa-play' : 'fa-solid fa-image'); + + let thumbContentHtml = ''; + if (isAudio && !video.has_coverart) { + thumbContentHtml = ` + + `; + } else { + thumbContentHtml = ` + ${escapeHtml(displayTitle)} + + `; + } + + const forYouBadge = video.personalized + ? ` For You` + : ''; + + return ` + + `; + }; + + const getSessionAffinityParams = () => { + try { + const aff = window.f0ckInterestEngine ? window.f0ckInterestEngine.getTopAffinities() : null; + if (!aff) return ''; + let q = ''; + if (aff.topTags && aff.topTags.length > 0) { + q += `&session_tags=${encodeURIComponent(aff.topTags.join(','))}`; + } + if (aff.topCreators && aff.topCreators.length > 0) { + q += `&session_creators=${encodeURIComponent(aff.topCreators.join(','))}`; + } + return q; + } catch (_) { + return ''; + } + }; + + const loadRecommendations = async (silent = false) => { + const container = document.getElementById('sidebar-recommendations-container'); + if (!container || recommendationsLoading) return; + + recommendationsLoading = true; + if (!silent && !recommendationsLoaded) { + container.innerHTML = ` + + `; + } + + try { + seenRecommendationIds.clear(); + const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : ''; + const affParams = getSessionAffinityParams(); + const res = await fetch(`/api/v2/recommendations?limit=${RECOMMENDATIONS_LIMIT}&mode=${mode}${affParams}`, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + const data = await res.json(); + const items = data.items || data.videos || []; + + if (data.success && items.length > 0) { + currentRecommendations = items; + recommendationsLoaded = true; + + let html = ''; + items.forEach(v => { + seenRecommendationIds.add(v.id); + html += renderVideoCard(v); + }); + container.innerHTML = html; + if (silent) { + container.scrollTop = 0; + } + attachRecommendationSentinel(); + bindRecommendationEvents(); + } else { + container.innerHTML = ` +
+ ${window.f0ckI18n?.sidebar_no_recommendations || 'No recommendations found.'} +
+ `; + } + } catch (e) { + console.error("Sidebar Recommendations: Failed to load", e); + if (!recommendationsLoaded) { + container.innerHTML = `
${window.f0ckI18n?.sidebar_failed_to_load || 'Failed to load.'}
`; + } + } finally { + recommendationsLoading = false; + } + }; + + const loadMoreRecommendations = async () => { + const container = document.getElementById('sidebar-recommendations-container'); + if (!container || recommendationsLoading || recommendationsLoadingMore || !recommendationsLoaded) return; + + recommendationsLoadingMore = true; + + let indicator = document.getElementById('sidebar-recommendations-load-more'); + if (!indicator) { + indicator = document.createElement('div'); + indicator.id = 'sidebar-recommendations-load-more'; + indicator.style.cssText = 'text-align:center;padding:12px 0;font-size:0.85em;color:#888;'; + indicator.innerHTML = ''; + } + container.appendChild(indicator); + + try { + const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : ''; + const excludeArr = Array.from(seenRecommendationIds).slice(-100); + const affParams = getSessionAffinityParams(); + const res = await fetch(`/api/v2/recommendations?limit=15&mode=${mode}&exclude_ids=${excludeArr.join(',')}${affParams}`, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + const data = await res.json(); + const items = data.items || data.videos || []; + + if (indicator && indicator.parentNode) { + indicator.remove(); + } + + if (data.success && items.length > 0) { + const freshItems = items.filter(v => !seenRecommendationIds.has(v.id)); + if (freshItems.length > 0) { + const temp = document.createElement('div'); + let html = ''; + freshItems.forEach(v => { + seenRecommendationIds.add(v.id); + html += renderVideoCard(v); + }); + temp.innerHTML = html; + while (temp.firstChild) { + container.appendChild(temp.firstChild); + } + attachRecommendationSentinel(); + } + } + } catch (e) { + console.error("Sidebar Recommendations: Failed to load more", e); + if (indicator && indicator.parentNode) { + indicator.remove(); + } + } finally { + recommendationsLoadingMore = false; + } + }; + + const attachRecommendationSentinel = () => { + const container = document.getElementById('sidebar-recommendations-container'); + if (!container) return; + + if (!recIoSentinel) { + recIoSentinel = document.createElement('div'); + recIoSentinel.id = 'sidebar-recommendations-io-sentinel'; + recIoSentinel.style.height = '1px'; + } + + container.appendChild(recIoSentinel); + + if (typeof IntersectionObserver !== 'undefined') { + if (!recObserver) { + recObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && recommendationsLoaded && !recommendationsLoading && !recommendationsLoadingMore) { + loadMoreRecommendations(); + } + }, { root: container, rootMargin: '0px 0px 250px 0px', threshold: 0 }); + } + recObserver.disconnect(); + recObserver.observe(recIoSentinel); + } + }; + + const bindRecommendationScrollListener = () => { + const container = document.getElementById('sidebar-recommendations-container'); + if (!container) return; + + // Fallback for environments without IntersectionObserver + if (typeof IntersectionObserver === 'undefined') { + container.addEventListener('scroll', () => { + if (recommendationsLoading || recommendationsLoadingMore || !recommendationsLoaded) return; + const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 150; + if (nearBottom) loadMoreRecommendations(); + }, { passive: true }); + } + }; + + const loadVideoRecommendations = loadRecommendations; + + const switchSidebarTab = (tabName) => { + const normalizedTab = (tabName === 'videos' || tabName === 'recommendations') ? 'recommendations' : tabName; + const tabs = document.querySelectorAll('.sidebar-tab'); + const contents = document.querySelectorAll('.sidebar-tab-content'); + + tabs.forEach(t => { + const tTab = (t.dataset.tab === 'videos' || t.dataset.tab === 'recommendations') ? 'recommendations' : t.dataset.tab; + if (tTab === normalizedTab) { + t.classList.add('active'); + } else { + t.classList.remove('active'); + } + }); + + contents.forEach(c => { + const cTab = (c.dataset.tabContent === 'videos' || c.dataset.tabContent === 'recommendations') ? 'recommendations' : c.dataset.tabContent; + if (cTab === normalizedTab) { + c.classList.add('active'); + c.style.display = 'block'; + } else { + c.classList.remove('active'); + c.style.display = 'none'; + } + }); + + try { + localStorage.setItem('sidebar_active_tab', normalizedTab); + } catch (_) {} + + if (normalizedTab === 'recommendations') { + if (!recommendationsLoaded) { + loadRecommendations(); + } + } + }; + + const initSidebarTabs = () => { + const tabsContainer = document.querySelector('.sidebar-tabs'); + if (tabsContainer) { + tabsContainer.addEventListener('click', (e) => { + const btn = e.target.closest('.sidebar-tab'); + if (btn && btn.dataset.tab) { + e.preventDefault(); + switchSidebarTab(btn.dataset.tab); + } + }); + } + + // Restore persisted active tab if any + let savedTab = 'comments'; + try { + savedTab = localStorage.getItem('sidebar_active_tab') || 'comments'; + } catch (_) {} + + if (savedTab === 'videos' || savedTab === 'recommendations') { + switchSidebarTab('recommendations'); + } + }; + + const replaceCardWithNewRandom = async (card) => { + if (!card || card.dataset.swapping === 'true') return; + card.dataset.swapping = 'true'; + card.classList.add('sidebar-card-swapping'); + + const clickedId = parseInt(card.dataset.id, 10); + + try { + const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : ''; + const container = document.getElementById('sidebar-recommendations-container'); + const visibleIds = []; + if (container) { + container.querySelectorAll('.sidebar-video-card').forEach(c => { + const cid = parseInt(c.dataset.id, 10); + if (cid) visibleIds.push(cid); + }); + } + + const excludeSet = new Set([...seenRecommendationIds, ...visibleIds]); + if (clickedId) { + excludeSet.add(clickedId); + if (window.f0ckInterestEngine) { + window.f0ckInterestEngine.recordSuggestionClick(clickedId); + } + } + const excludeArr = Array.from(excludeSet).slice(-120); + + const affParams = getSessionAffinityParams(); + const res = await fetch(`/api/v2/recommendations?limit=1&mode=${mode}&exclude_ids=${excludeArr.join(',')}${affParams}`, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + const data = await res.json(); + const items = data.items || data.videos || []; + + if (data.success && items.length > 0) { + const newItem = items[0]; + seenRecommendationIds.add(newItem.id); + + const idx = currentRecommendations.findIndex(v => v.id === clickedId); + if (idx !== -1) { + currentRecommendations[idx] = newItem; + } else { + currentRecommendations.push(newItem); + } + + const temp = document.createElement('div'); + temp.innerHTML = renderVideoCard(newItem).trim(); + const newCard = temp.firstElementChild; + + if (newCard && card.parentNode) { + newCard.classList.add('sidebar-card-entering'); + card.replaceWith(newCard); + setTimeout(() => { + newCard.classList.remove('sidebar-card-entering'); + }, 2200); + } + } else { + card.classList.remove('sidebar-card-swapping'); + delete card.dataset.swapping; + } + } catch (e) { + console.error("Sidebar Recommendations: Failed to swap clicked card", e); + if (card) { + card.classList.remove('sidebar-card-swapping'); + delete card.dataset.swapping; + } + } + }; + + const bindRecommendationEvents = () => { + const container = document.getElementById('sidebar-recommendations-container'); + if (!container || container.dataset.eventsBound === 'true') return; + container.dataset.eventsBound = 'true'; + + const handleCardAction = (e) => { + // Only respond to primary click (0) or middle-click (1). Ignore right click (2). + if (e.button !== 0 && e.button !== 1) return; + + const card = e.target.closest('.sidebar-video-card'); + if (card) { + replaceCardWithNewRandom(card); + } + }; + + container.addEventListener('click', handleCardAction); + container.addEventListener('auxclick', handleCardAction); + }; + + // Reload recommendations when user triggers Random (#random, #nav-random, or 'r' key) + const handleRandomAction = () => { + recommendationsLoaded = false; + let savedTab = 'comments'; + try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {} + if (savedTab === 'videos' || savedTab === 'recommendations') { + loadRecommendations(false); + } + }; + + document.addEventListener('f0ck:randomTriggered', handleRandomAction); + document.addEventListener('click', (e) => { + const btn = e.target.closest('#random, #nav-random, a[href="/random"], a[href$="/random"]'); + if (btn) { + handleRandomAction(); + } + }, true); + // Handle explicit mode changes (e.g. from item page where full transition doesn't occur) document.addEventListener('f0ck:modeChanged', (e) => { window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode); @@ -1066,6 +1523,13 @@ currentPage = 1; hasMore = true; loadActivity(false); + + recommendationsLoaded = false; + let savedTab = 'comments'; + try { savedTab = localStorage.getItem('sidebar_active_tab'); } catch (_) {} + if (savedTab === 'videos' || savedTab === 'recommendations') { + loadRecommendations(false); + } }); // When the current user posts a comment, silently refresh sidebar to show it @@ -1108,6 +1572,9 @@ // Initial load const _origInit = init; const initWithScroll = async () => { + initSidebarTabs(); + bindRecommendationEvents(); + bindRecommendationScrollListener(); await _origInit(); bindScrollListener(); }; diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index ffa2573..ca8bb60 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -460,6 +460,9 @@ "acknowledge": "Verstanden" }, "sidebar": { + "recent_comments": "Neueste Kommentare", + "recommendations": "Empfehlungen", + "recommended_videos": "Video-Empfehlungen", "loading_activity": "Aktivität wird geladen...", "no_activity": "Keine kürzliche Aktivität.", "failed_to_load": "Laden fehlgeschlagen.", @@ -468,7 +471,10 @@ "view": "Ansehen", "read_more": "mehr sehen", "see_less": "weniger anzeigen", - "show_full_comment": "ganzen Kommentar anzeigen" + "show_full_comment": "ganzen Kommentar anzeigen", + "loading_recommendations": "Lade Empfehlungen...", + "no_recommendations": "Keine Empfehlungen gefunden.", + "refresh_recommendations": "Empfehlungen aktualisieren" }, "subscriptions": { "title": "Meine Abonnements", diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index 9853d3e..3cd2657 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -464,6 +464,10 @@ "acknowledge": "I Understand" }, "sidebar": { + "recent_comments": "Recent Comments", + "recommendations": "Recommendations", + "recommended_videos": "Recommended Videos", + "no_recommendations": "No recommendations found.", "loading_activity": "Loading activity...", "no_activity": "No recent activity.", "failed_to_load": "Failed to load.", @@ -472,7 +476,10 @@ "view": "View", "read_more": "read more", "see_less": "see less", - "show_full_comment": "show full comment" + "show_full_comment": "show full comment", + "loading_recommendations": "Loading recommendations...", + "no_recommendations": "No video recommendations found.", + "refresh_recommendations": "Refresh recommendations" }, "subscriptions": { "title": "My Subscriptions", diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json index 2878fdf..42a4801 100644 --- a/src/inc/locales/nl.json +++ b/src/inc/locales/nl.json @@ -458,6 +458,9 @@ "acknowledge": "Ik Begrijp het" }, "sidebar": { + "recent_comments": "Recente reacties", + "recommendations": "Aanbevelingen", + "recommended_videos": "Aanbevolen video's", "loading_activity": "Activiteit laden...", "no_activity": "Geen recente activiteit.", "failed_to_load": "Laden mislukt.", @@ -466,7 +469,10 @@ "view": "Bekijken", "read_more": "lees meer", "see_less": "zie minder", - "show_full_comment": "volledig commentaar tonen" + "show_full_comment": "volledig commentaar tonen", + "loading_recommendations": "Aanbevelingen laden...", + "no_recommendations": "Geen aanbevelingen gevonden.", + "refresh_recommendations": "Aanbevelingen vernieuwen" }, "subscriptions": { "title": "Mijn Abonnementen", diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json index 1e7fd2c..6a4c3c2 100644 --- a/src/inc/locales/zange.json +++ b/src/inc/locales/zange.json @@ -459,6 +459,9 @@ "acknowledge": "Ich verstehe" }, "sidebar": { + "recent_comments": "Frische Kommis", + "recommendations": "Zufallskram", + "recommended_videos": "Filmchen-Tipps", "loading_activity": "Aktivität wird geladen...", "no_activity": "Noch keine Aktivität", "failed_to_load": "Ladung gescheitert.", @@ -467,7 +470,10 @@ "view": "Ansehen", "read_more": "mehr sehen", "see_less": "weniger sehen", - "show_full_comment": "Kommentar vollständig ausklappen" + "show_full_comment": "Kommentar vollständig ausklappen", + "loading_recommendations": "Lade Empfehlungen...", + "no_recommendations": "Nix am Start.", + "refresh_recommendations": "Neu würfeln" }, "subscriptions": { "title": "Meine Abonnements", diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index 3f58e1a..adb0f09 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -408,7 +408,7 @@ const buildFeedFilters = async ({ }; }; -export default { +const f0cklib = { getItemPage: async ({ targetItemId, targetItemPinned, @@ -2049,6 +2049,475 @@ export default { } }, + getRandomRecommendations: async ({ limit = 20, mode, ratings, session, exclude, user_id, is_admin, mime, exclude_ids } = {}) => { + const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null; + const modequery = computeBaseMode(mode, ratingsArr, session); + const globalfilter = !session ? getGlobalfilter() : null; + const excludedTags = session && exclude ? (exclude || []) : []; + const maxLimit = Math.min(Math.max(1, Number(limit) || 20), 50); + + const isOwnerOrAdmin = session && is_admin; + + const visibilityFilter = isOwnerOrAdmin + ? db`` + : (session && user_id + ? db`AND (COALESCE(items.visibility, 0) = 0 OR items.username = (SELECT "user" FROM "user" WHERE id = ${user_id}))` + : db`AND COALESCE(items.visibility, 0) = 0`); + + const excludeItemIds = Array.isArray(exclude_ids) + ? exclude_ids.map(Number).filter(n => Number.isInteger(n) && n > 0) + : (typeof exclude_ids === 'string' + ? exclude_ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0) + : []); + + const excludeIdsFilter = excludeItemIds.length > 0 + ? db`AND items.id != ALL(${excludeItemIds}::int[])` + : db``; + + const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); + const mimeSQL = mimeParts.length > 0 + ? db`and (${mimeParts.map(m => m === 'flash' + ? (flashMimes.length > 0 + ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) + : db`false`) + : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` + : db``; + + let rows; + if (mimeParts.length > 0) { + rows = await db` + WITH rand_items AS ( + SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart + FROM items + WHERE items.active = true + AND (items.is_deleted IS NOT TRUE) + ${mimeSQL} + ${visibilityFilter} + ${excludeIdsFilter} + AND ${db.unsafe(modequery)} + ${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``} + ${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``} + ORDER BY random() + LIMIT ${maxLimit} + ) + SELECT + ri.*, + uo.display_name, + uo.username_color, + uo.avatar, + uo.avatar_file, + (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = ri.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id, + ARRAY( + SELECT t.tag + FROM tags_assign ta + JOIN tags t ON t.id = ta.tag_id + WHERE ta.item_id = ri.id AND ta.tag_id NOT IN (1, 2, 3) + LIMIT 3 + ) as tags + FROM rand_items ri + LEFT JOIN "user" u ON LOWER(u."user") = LOWER(ri.username) + LEFT JOIN user_options uo ON uo.user_id = u.id + `; + } else { + // Balanced mix across all available media types: ensure audio/music gets guaranteed representation alongside images & videos + const audioLimit = Math.max(1, Math.min(3, Math.floor(maxLimit * 0.15))); + rows = await db` + WITH rand_audio AS ( + SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart + FROM items + WHERE items.active = true + AND (items.is_deleted IS NOT TRUE) + AND items.mime ILIKE 'audio/%' + ${visibilityFilter} + ${excludeIdsFilter} + AND ${db.unsafe(modequery)} + ${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``} + ${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``} + ORDER BY random() + LIMIT ${audioLimit} + ), + rand_other AS ( + SELECT items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart + FROM items + WHERE items.active = true + AND (items.is_deleted IS NOT TRUE) + AND items.mime NOT ILIKE 'audio/%' + ${visibilityFilter} + ${excludeIdsFilter} + AND ${db.unsafe(modequery)} + ${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``} + ${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``} + ORDER BY random() + LIMIT ${maxLimit} + ), + rand_items AS ( + SELECT * FROM ( + SELECT * FROM rand_audio + UNION ALL + SELECT * FROM rand_other + ) sub + ORDER BY random() + LIMIT ${maxLimit} + ) + SELECT + ri.*, + uo.display_name, + uo.username_color, + uo.avatar, + uo.avatar_file, + (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = ri.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id, + ARRAY( + SELECT t.tag + FROM tags_assign ta + JOIN tags t ON t.id = ta.tag_id + WHERE ta.item_id = ri.id AND ta.tag_id NOT IN (1, 2, 3) + LIMIT 3 + ) as tags + FROM rand_items ri + LEFT JOIN "user" u ON LOWER(u."user") = LOWER(ri.username) + LEFT JOIN user_options uo ON uo.user_id = u.id + `; + } + + return rows.map(r => { + const meta = xdScoreMeta(r.xd_score); + const tagId = r.rating_tag_id; + const ratingClass = tagId === 1 ? 'sfw' : (tagId === 2 ? 'nsfw' : (tagId === 3 ? 'nsfl' : 'untagged')); + return { + id: r.id, + title: r.title || null, + slug: r.slug || null, + mime: r.mime, + dest: r.dest, + username: r.username, + display_name: r.display_name || r.username, + username_color: r.username_color || null, + avatar: r.avatar || null, + avatar_file: r.avatar_file || null, + stamp: r.stamp, + tags: r.tags || [], + rating_tag_id: tagId, + rating_class: ratingClass, + xd_score: r.xd_score, + xd_tier: meta.tier, + xd_label: meta.label, + width: r.width, + height: r.height, + has_coverart: !!r.has_coverart + }; + }); + }, + + getRandomVideos: (args) => f0cklib.getRandomRecommendations({ ...args, mime: 'video' }), + + updateUserAffinity: async ({ user_id, item_id, scoreDelta = 1.0 }) => { + if (!user_id || !item_id || !scoreDelta) return; + try { + const itemInfo = await db` + SELECT i.username, COALESCE(array_agg(ta.tag_id) FILTER (WHERE ta.tag_id IS NOT NULL), '{}') as tag_ids + FROM items i + LEFT JOIN tags_assign ta ON ta.item_id = i.id + WHERE i.id = ${item_id} + GROUP BY i.id, i.username + LIMIT 1 + `; + if (itemInfo.length === 0) return; + const { username: creator, tag_ids } = itemInfo[0]; + + // 1. Update tag affinities + if (tag_ids && tag_ids.length > 0) { + await db` + INSERT INTO user_tag_affinity (user_id, tag_id, score, interaction_count, last_interacted) + SELECT + ${user_id}, + unnest(${tag_ids}::int[]), + ${scoreDelta}, + 1, + now() + ON CONFLICT (user_id, tag_id) DO UPDATE SET + score = GREATEST(-10.0, LEAST(1000.0, user_tag_affinity.score + EXCLUDED.score)), + interaction_count = user_tag_affinity.interaction_count + 1, + last_interacted = now() + `; + } + + // 2. Update creator affinity + if (creator && creator.trim()) { + await db` + INSERT INTO user_creator_affinity (user_id, creator_username, score, interaction_count, last_interacted) + VALUES (${user_id}, ${creator.trim()}, ${scoreDelta}, 1, now()) + ON CONFLICT (user_id, creator_username) DO UPDATE SET + score = GREATEST(-10.0, LEAST(1000.0, user_creator_affinity.score + EXCLUDED.score)), + interaction_count = user_creator_affinity.interaction_count + 1, + last_interacted = now() + `; + } + } catch (err) { + console.error("[AFFINITY] Failed to update user affinity:", err); + } + }, + + decayUserAffinities: async () => { + try { + await db` + UPDATE user_tag_affinity + SET score = score * 0.85 + WHERE last_interacted < now() - interval '7 days' AND score > 0.1 + `; + await db` + UPDATE user_creator_affinity + SET score = score * 0.85 + WHERE last_interacted < now() - interval '7 days' AND score > 0.1 + `; + await db`DELETE FROM user_tag_affinity WHERE score < 0.05 AND interaction_count < 2`; + await db`DELETE FROM user_creator_affinity WHERE score < 0.05 AND interaction_count < 2`; + } catch (err) { + console.error("[AFFINITY] Decay job error:", err); + } + }, + + getPersonalizedRecommendations: async ({ + limit = 20, + mode, + ratings, + session, + exclude, + user_id, + is_admin, + mime, + exclude_ids, + session_tags = '', + session_creators = '' + } = {}) => { + const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null; + const modequery = computeBaseMode(mode, ratingsArr, session); + const globalfilter = !session ? getGlobalfilter() : null; + const excludedTags = session && exclude ? (exclude || []) : []; + const maxLimit = Math.min(Math.max(1, Number(limit) || 20), 50); + + const isOwnerOrAdmin = session && is_admin; + + const visibilityFilter = isOwnerOrAdmin + ? db`` + : (session && user_id + ? db`AND (COALESCE(items.visibility, 0) = 0 OR items.username = (SELECT "user" FROM "user" WHERE id = ${user_id}))` + : db`AND COALESCE(items.visibility, 0) = 0`); + + const excludeItemIds = Array.isArray(exclude_ids) + ? exclude_ids.map(Number).filter(n => Number.isInteger(n) && n > 0) + : (typeof exclude_ids === 'string' + ? exclude_ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0) + : []); + + // 1. Gather User & Session Profile + const tagMap = new Map(); + const creatorSet = new Set(); + + if (user_id) { + try { + const dbTags = await db` + SELECT tag_id, score + FROM user_tag_affinity + WHERE user_id = ${user_id} AND score > 0 + ORDER BY score DESC + LIMIT 30 + `; + dbTags.forEach(r => tagMap.set(r.tag_id, Number(r.score))); + + const dbCreators = await db` + SELECT creator_username + FROM user_creator_affinity + WHERE user_id = ${user_id} AND score > 0 + ORDER BY score DESC + LIMIT 15 + `; + dbCreators.forEach(r => { if (r.creator_username) creatorSet.add(r.creator_username); }); + } catch (err) { + console.error("[RECS] Failed to load user affinity profile:", err); + } + } + + // Process session tags (for guests or live in-session acceleration) + const stList = (typeof session_tags === 'string' ? session_tags.split(',') : (Array.isArray(session_tags) ? session_tags : [])) + .map(s => s.trim().toLowerCase()) + .filter(Boolean) + .slice(0, 20); + + if (stList.length > 0) { + try { + const stRows = await db`SELECT id, tag FROM tags WHERE LOWER(tag) = ANY(${stList}::text[])`; + stRows.forEach(r => { + tagMap.set(r.id, (tagMap.get(r.id) || 0) + 8.0); + }); + } catch (err) { + console.error("[RECS] Failed to resolve session tags:", err); + } + } + + // Process session creators + const scList = (typeof session_creators === 'string' ? session_creators.split(',') : (Array.isArray(session_creators) ? session_creators : [])) + .map(s => s.trim()) + .filter(Boolean) + .slice(0, 15); + scList.forEach(c => creatorSet.add(c)); + + const targetTagEntries = Array.from(tagMap.entries()).filter(([tid, s]) => s > 0).slice(0, 35); + const targetCreators = Array.from(creatorSet); + + // If cold start (no learned tags and no creators): fallback directly to pure random recommendations + if (targetTagEntries.length === 0 && targetCreators.length === 0) { + return f0cklib.getRandomRecommendations({ + limit: maxLimit, + mode, + ratings, + session, + exclude, + user_id, + is_admin, + mime, + exclude_ids: excludeItemIds + }); + } + + // 2. Personalization vs Serendipity Split (60% personalized, 40% random exploration) + let personalizedTarget; + if (maxLimit === 1) { + // For single-card replacement: 65% chance personalized, 35% chance discovery + personalizedTarget = Math.random() < 0.65 ? 1 : 0; + } else { + personalizedTarget = Math.round(maxLimit * 0.60); + } + + const targetTagIds = targetTagEntries.map(([tid]) => tid); + const targetTagScores = targetTagEntries.map(([, score]) => score); + + const excludeIdsFilter = excludeItemIds.length > 0 + ? db`AND items.id != ALL(${excludeItemIds}::int[])` + : db``; + + const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); + const mimeSQL = mimeParts.length > 0 + ? db`and (${mimeParts.map(m => m === 'flash' + ? (flashMimes.length > 0 + ? flashMimes.map(fm => db`items.mime = ${fm}`).reduce((a, b) => db`${a} or ${b}`) + : db`false`) + : (m === 'pdf' ? db`items.mime = 'application/pdf'` : db`items.mime ilike ${m + '/%'}`)).reduce((a, b) => db`${a} or ${b}`)})` + : db``; + + let personalizedItems = []; + if (personalizedTarget > 0 && targetTagIds.length > 0) { + try { + const poolLimit = Math.max(personalizedTarget * 3, 25); + const candidateRows = await db` + WITH user_tags AS ( + SELECT unnest(${targetTagIds}::int[]) as tag_id, unnest(${targetTagScores}::real[]) as score + ), + candidate_pool AS ( + SELECT + items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart, + ( + SUM(ut.score) * 1.5 + + CASE WHEN items.username = ANY(${targetCreators}::text[]) THEN 20.0 ELSE 0.0 END + + (random() * 12.0) + ) as rank_score + FROM items + JOIN tags_assign ta ON ta.item_id = items.id + JOIN user_tags ut ON ut.tag_id = ta.tag_id + WHERE items.active = true + AND (items.is_deleted IS NOT TRUE) + ${visibilityFilter} + ${excludeIdsFilter} + ${mimeSQL} + AND ${db.unsafe(modequery)} + ${globalfilter ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(globalfilter)}))` : db``} + ${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``} + GROUP BY items.id, items.title, items.slug, items.mime, items.dest, items.username, items.stamp, items.xd_score, items.width, items.height, items.has_coverart + ORDER BY rank_score DESC + LIMIT ${poolLimit} + ) + SELECT + cp.*, + uo.display_name, + uo.username_color, + uo.avatar, + uo.avatar_file, + (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = cp.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) LIMIT 1) as rating_tag_id, + ARRAY( + SELECT t.tag + FROM tags_assign ta + JOIN tags t ON t.id = ta.tag_id + WHERE ta.item_id = cp.id AND ta.tag_id NOT IN (1, 2, 3) + LIMIT 3 + ) as tags + FROM ( + SELECT * FROM candidate_pool ORDER BY random() LIMIT ${personalizedTarget} + ) cp + LEFT JOIN "user" u ON LOWER(u."user") = LOWER(cp.username) + LEFT JOIN user_options uo ON uo.user_id = u.id + `; + + personalizedItems = candidateRows.map(r => { + const meta = xdScoreMeta(r.xd_score); + const tagId = r.rating_tag_id; + const ratingClass = tagId === 1 ? 'sfw' : (tagId === 2 ? 'nsfw' : (tagId === 3 ? 'nsfl' : 'untagged')); + return { + id: r.id, + title: r.title || null, + slug: r.slug || null, + mime: r.mime, + dest: r.dest, + username: r.username, + display_name: r.display_name || r.username, + username_color: r.username_color || null, + avatar: r.avatar || null, + avatar_file: r.avatar_file || null, + stamp: r.stamp, + tags: r.tags || [], + rating_tag_id: tagId, + rating_class: ratingClass, + xd_score: r.xd_score, + xd_tier: meta.tier, + xd_label: meta.label, + width: r.width, + height: r.height, + has_coverart: !!r.has_coverart, + personalized: true + }; + }); + } catch (err) { + console.error("[RECS] Error querying personalized candidate pool:", err); + } + } + + // 3. Fetch Random Exploration Items + const neededRandom = maxLimit - personalizedItems.length; + let randomItems = []; + if (neededRandom > 0) { + const allExclude = [...excludeItemIds, ...personalizedItems.map(p => p.id)]; + randomItems = await f0cklib.getRandomRecommendations({ + limit: neededRandom, + mode, + ratings, + session, + exclude, + user_id, + is_admin, + mime, + exclude_ids: allExclude + }); + } + + // 4. Combine & Interweave with Fisher-Yates Shuffle + const combined = [...personalizedItems, ...randomItems]; + for (let i = combined.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [combined[i], combined[j]] = [combined[j], combined[i]]; + } + + return combined.slice(0, maxLimit); + }, + + computeBaseMode, getGlobalfilter, processMentions, @@ -2059,4 +2528,6 @@ export default { clearCountCache: () => countCache.clear() }; +export default f0cklib; + diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 5777e54..854884e 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -707,6 +707,103 @@ export default router => { } }); }); + + group.get(/\/recommendations(?:\/(?videos|all))?$/, async (req, res) => { + try { + const limit = Math.min(+(req.url.qs?.limit || 20), 50); + let mode = req.mode ?? 0; + if (req.url.qs && req.url.qs.mode && ['0', '1', '2', '3'].includes(req.url.qs.mode)) { + mode = parseInt(req.url.qs.mode); + } + const ratingsRaw = req.cookies.ratings; + const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null; + + const mime = req.params?.type === 'videos' ? 'video' : (req.url.qs?.mime || null); + + let excludeIds = []; + if (req.url.qs?.exclude_ids) { + excludeIds = String(req.url.qs.exclude_ids).split(',').map(Number).filter(n => Number.isInteger(n) && n > 0).slice(0, 150); + } + + const sessionTags = req.url.qs?.session_tags || ''; + const sessionCreators = req.url.qs?.session_creators || ''; + + const items = await f0cklib.getPersonalizedRecommendations({ + limit, + mode, + ratings: ratingsArr, + session: !!req.session, + exclude: req.session?.excluded_tags || [], + user_id: req.session?.id, + is_admin: req.session?.admin, + mime, + exclude_ids: excludeIds, + session_tags: sessionTags, + session_creators: sessionCreators + }); + + res.json({ + success: true, + items, + videos: items + }); + } catch (err) { + console.error("[RECOMMENDATIONS] Failed to fetch personalized recommendations:", err); + res.json({ + success: false, + items: [], + videos: [], + error: "Failed to load recommendations" + }); + } + }); + + // Track user behavioral signals (dwell time, completion, skips, suggestion clicks) + group.post(/\/track\/interaction$/, async (req, res) => { + try { + let payload = req.post || {}; + if (!payload || Object.keys(payload).length === 0) { + try { + const body = await collectBody(req); + if (body && body.length > 0) payload = JSON.parse(body.toString()); + } catch (_) {} + } + + const itemId = parseInt(payload.item_id || payload.itemId, 10); + if (!itemId) { + return res.json({ success: false, error: "Invalid item_id" }, 400); + } + + const duration = parseFloat(payload.duration) || 0; + const percent = parseFloat(payload.percent) || 0; + const type = String(payload.type || 'dwell'); + + // Behavioral weighting: time spent is a primary signal + let delta = 1.0; + if (type === 'skip' || (duration < 2.5 && percent < 15)) { + delta = -1.5; + } else if (type === 'click_suggestion') { + delta = 2.5; + } else if (type === 'finish' || percent >= 75 || duration >= 20) { + delta = 3.0; + } else if (duration >= 8 || percent >= 35) { + delta = 1.5; + } + + if (req.session?.id) { + f0cklib.updateUserAffinity({ + user_id: req.session.id, + item_id: itemId, + scoreDelta: delta + }).catch(err => console.error("[TRACK] Affinity update failed:", err)); + } + + return res.json({ success: true, delta }); + } catch (err) { + console.error("[TRACK] Interaction error:", err); + return res.json({ success: false, error: "Tracking failed" }, 500); + } + }); group.get(/\/orakel\/user$/, async (req, res) => { try { @@ -1201,6 +1298,7 @@ export default router => { where user_id = ${+req.session.id} and item_id = ${+postid} `; + f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: -5.0 }).catch(() => {}); } else { // add fav — ON CONFLICT DO NOTHING guards against rapid double-taps await db` @@ -1210,6 +1308,7 @@ export default router => { }, 'item_id', 'user_id')} on conflict do nothing `; + f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: 5.0 }).catch(() => {}); } const favs = await db` diff --git a/views/snippets/footer.html b/views/snippets/footer.html index 893942e..377e441 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -120,12 +120,26 @@ @if(!private_society || session)