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)
+ ? ``
+ : '';
+
+ 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 ? `` : '';
+ 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 = `
+
+
+ `;
+ }
+
+ const forYouBadge = video.personalized
+ ? ``
+ : '';
+
+ 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)