f0ck algos
This commit is contained in:
@@ -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);
|
||||||
+442
-12
@@ -2229,7 +2229,7 @@ html[theme='95'] .item-main-content .metadata {
|
|||||||
width: 320px;
|
width: 320px;
|
||||||
background: var(--dropdown-bg);
|
background: var(--dropdown-bg);
|
||||||
border: 1px solid var(--nav-border-color);
|
border: 1px solid var(--nav-border-color);
|
||||||
z-index: 1000;
|
z-index: 10000 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Arrow caret pointing up toward the icon */
|
/* Arrow caret pointing up toward the icon */
|
||||||
@@ -2585,6 +2585,7 @@ body.layout-modern .global-sidebar-right {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
top: var(--navbar-h, 50px) !important;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -2625,6 +2626,7 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
top: var(--navbar-h, 50px) !important;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
@@ -2918,6 +2920,432 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
|||||||
letter-spacing: 1px;
|
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 {
|
#sidebar-activity-container.sidebar-comments-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -9735,7 +10163,7 @@ input#s_avatar {
|
|||||||
background: var(--dropdown-bg);
|
background: var(--dropdown-bg);
|
||||||
border: 1px solid var(--nav-border-color);
|
border: 1px solid var(--nav-border-color);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
z-index: 10000;
|
z-index: 10000 !important;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11010,10 +11438,6 @@ video.sidebar-comment-img.emoji {
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-video-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-content img:not(.emoji) {
|
.comment-content img:not(.emoji) {
|
||||||
display: block;
|
display: block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -11522,7 +11946,7 @@ div.sbt {
|
|||||||
body > nav.navbar {
|
body > nav.navbar {
|
||||||
position: sticky !important;
|
position: sticky !important;
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
z-index: 1005 !important;
|
z-index: 10070 !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
@@ -11648,17 +12072,22 @@ body > nav.navbar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Right-anchor all dropdowns inside nav-right-group */
|
/* 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;
|
left: auto;
|
||||||
right: 0;
|
right: 0;
|
||||||
transform: none;
|
transform: none;
|
||||||
|
z-index: 10000 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-right-group .notif-dropdown {
|
.nav-right-group .notif-dropdown,
|
||||||
|
#notif-dropdown {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 0;
|
right: 0;
|
||||||
/* anchor to bell container right edge; JS overrides on mobile */
|
/* anchor to bell container right edge; JS overrides on mobile */
|
||||||
transform: none;
|
transform: none;
|
||||||
|
z-index: 10000 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shift arrow to sit directly under the bell icon */
|
/* Shift arrow to sit directly under the bell icon */
|
||||||
@@ -19075,7 +19504,7 @@ body.onara-modal-open nav.navbar {
|
|||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
filter: none !important;
|
filter: none !important;
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
z-index: 10060 !important;
|
z-index: 10070 !important;
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
left: 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 */
|
/* Ensure hamburger dropdown is interactive and above the Onara modal */
|
||||||
body.onara-modal-open nav.navbar .nav-collapse {
|
body.onara-modal-open nav.navbar .nav-collapse {
|
||||||
z-index: 10065 !important;
|
z-index: 10075 !important;
|
||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19098,7 +19527,8 @@ body.onara-modal-open .global-sidebar-right {
|
|||||||
body.onara-modal-open #sidebar-drag-zone {
|
body.onara-modal-open #sidebar-drag-zone {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
pointer-events: auto !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 {
|
body.onara-modal-open .admin-bar {
|
||||||
|
|||||||
+200
-3
@@ -4739,6 +4739,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
// Special check for random
|
// Special check for random
|
||||||
if (link.id === 'random' || link.id === 'nav-random') {
|
if (link.id === 'random' || link.id === 'nav-random') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
document.dispatchEvent(new CustomEvent('f0ck:randomTriggered'));
|
||||||
|
|
||||||
const outEls = document.querySelectorAll('.content, ._204863, .item_title');
|
const outEls = document.querySelectorAll('.content, ._204863, .item_title');
|
||||||
outEls.forEach(el => {
|
outEls.forEach(el => {
|
||||||
@@ -4970,9 +4971,9 @@ window.cancelAnimFrame = (function () {
|
|||||||
const parts = pathname.split('/').filter(Boolean);
|
const parts = pathname.split('/').filter(Boolean);
|
||||||
const isItemLink = isItemPath(pathname);
|
const isItemLink = isItemPath(pathname);
|
||||||
if (isItemLink) {
|
if (isItemLink) {
|
||||||
// Links inside comment bodies or MOTD should not inherit tag/hall/user context
|
// Links inside comment bodies, MOTD, or sidebar suggestions/recommendations should not inherit tag/hall/user context
|
||||||
const _inComment = anyLink.closest(".comment, .comment-content, .comment-body, .motd-content");
|
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, !_inComment);
|
loadItemAjax(targetUrl, !_skipInherit);
|
||||||
} else {
|
} else {
|
||||||
const isBrand = anyLink.classList.contains('navbar-brand');
|
const isBrand = anyLink.classList.contains('navbar-brand');
|
||||||
const fromModal = document.body.classList.contains('onara-modal-open') || !!anyLink.closest('#onara-modal');
|
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));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -1044,6 +1044,13 @@
|
|||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
loadActivity(false); // Force reload with loading state
|
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 {
|
} else {
|
||||||
// Immediately render from cache to avoid flicker
|
// Immediately render from cache to avoid flicker
|
||||||
renderFromCache();
|
renderFromCache();
|
||||||
@@ -1057,6 +1064,456 @@
|
|||||||
syncSidebarAndComments();
|
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)
|
||||||
|
? `<span class="sidebar-video-xd xd-tier-${video.xd_tier || 0}">xD ${video.xd_score}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
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 ? `<span class="sidebar-media-format-badge">${escapeHtml(ext)}</span>` : '';
|
||||||
|
const overlayIcon = isAudio ? 'fa-solid fa-music' : (isVideo ? 'fa-solid fa-play' : 'fa-solid fa-image');
|
||||||
|
|
||||||
|
let thumbContentHtml = '';
|
||||||
|
if (isAudio && !video.has_coverart) {
|
||||||
|
thumbContentHtml = `
|
||||||
|
<div class="sidebar-media-placeholder audio">
|
||||||
|
<i class="fa-solid fa-music"></i>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
thumbContentHtml = `
|
||||||
|
<img src="${thumbUrl}" class="sidebar-video-thumb" alt="${escapeHtml(displayTitle)}" loading="lazy" onerror="this.style.display='none'; if(this.nextElementSibling) this.nextElementSibling.classList.remove('hidden');" />
|
||||||
|
<div class="sidebar-media-placeholder ${isAudio ? 'audio' : ''} hidden">
|
||||||
|
<i class="${overlayIcon}"></i>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forYouBadge = video.personalized
|
||||||
|
? `<span class="sidebar-personalized-pill" title="Personalized recommendation based on your interests"><i class="fa-solid fa-wand-magic-sparkles"></i> For You</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="sidebar-video-card" data-id="${video.id}" data-slug="${escapeHtml(video.slug || '')}" data-personalized="${video.personalized ? 'true' : 'false'}">
|
||||||
|
<a href="/${videoKey}" class="sidebar-video-link" data-mode="${rClass}" data-inherit-context="false">
|
||||||
|
<div class="sidebar-video-thumb-wrap">
|
||||||
|
${thumbContentHtml}
|
||||||
|
<div class="sidebar-video-play-overlay"><i class="${overlayIcon}"></i></div>
|
||||||
|
${formatBadge}
|
||||||
|
<span class="sidebar-video-badge rating-${rClass}">${rClass.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-video-details">
|
||||||
|
<div class="sidebar-video-info">
|
||||||
|
<div class="sidebar-video-title" title="${escapeHtml(displayTitle)}">${escapeHtml(displayTitle)}</div>
|
||||||
|
<div class="sidebar-video-channel">
|
||||||
|
<span class="sidebar-video-user" ${userColorStyle}>${escapeHtml(authorName)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-video-meta">
|
||||||
|
${timeStr ? `<span class="sidebar-video-time" title="${escapeHtml(fullDate)}">${escapeHtml(timeStr)}</span>` : ''}
|
||||||
|
${(timeStr && xdBadge) ? `•` : ''}
|
||||||
|
${xdBadge}
|
||||||
|
${((timeStr || xdBadge) && forYouBadge) ? `•` : ''}
|
||||||
|
${forYouBadge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="sidebar-loading-state">
|
||||||
|
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||||
|
<span>${window.f0ckI18n?.sidebar_loading_recommendations || 'Loading recommendations...'}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div style="text-align:center;padding:20px;color:#888;">
|
||||||
|
${window.f0ckI18n?.sidebar_no_recommendations || 'No recommendations found.'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Sidebar Recommendations: Failed to load", e);
|
||||||
|
if (!recommendationsLoaded) {
|
||||||
|
container.innerHTML = `<div style="text-align:center;padding:20px;color:#888;">${window.f0ckI18n?.sidebar_failed_to_load || 'Failed to load.'}</div>`;
|
||||||
|
}
|
||||||
|
} 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 = '<i class="fa-solid fa-circle-notch fa-spin"></i>';
|
||||||
|
}
|
||||||
|
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)
|
// Handle explicit mode changes (e.g. from item page where full transition doesn't occur)
|
||||||
document.addEventListener('f0ck:modeChanged', (e) => {
|
document.addEventListener('f0ck:modeChanged', (e) => {
|
||||||
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
|
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
|
||||||
@@ -1066,6 +1523,13 @@
|
|||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
loadActivity(false);
|
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
|
// When the current user posts a comment, silently refresh sidebar to show it
|
||||||
@@ -1108,6 +1572,9 @@
|
|||||||
// Initial load
|
// Initial load
|
||||||
const _origInit = init;
|
const _origInit = init;
|
||||||
const initWithScroll = async () => {
|
const initWithScroll = async () => {
|
||||||
|
initSidebarTabs();
|
||||||
|
bindRecommendationEvents();
|
||||||
|
bindRecommendationScrollListener();
|
||||||
await _origInit();
|
await _origInit();
|
||||||
bindScrollListener();
|
bindScrollListener();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -460,6 +460,9 @@
|
|||||||
"acknowledge": "Verstanden"
|
"acknowledge": "Verstanden"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"recent_comments": "Neueste Kommentare",
|
||||||
|
"recommendations": "Empfehlungen",
|
||||||
|
"recommended_videos": "Video-Empfehlungen",
|
||||||
"loading_activity": "Aktivität wird geladen...",
|
"loading_activity": "Aktivität wird geladen...",
|
||||||
"no_activity": "Keine kürzliche Aktivität.",
|
"no_activity": "Keine kürzliche Aktivität.",
|
||||||
"failed_to_load": "Laden fehlgeschlagen.",
|
"failed_to_load": "Laden fehlgeschlagen.",
|
||||||
@@ -468,7 +471,10 @@
|
|||||||
"view": "Ansehen",
|
"view": "Ansehen",
|
||||||
"read_more": "mehr sehen",
|
"read_more": "mehr sehen",
|
||||||
"see_less": "weniger anzeigen",
|
"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": {
|
"subscriptions": {
|
||||||
"title": "Meine Abonnements",
|
"title": "Meine Abonnements",
|
||||||
|
|||||||
@@ -464,6 +464,10 @@
|
|||||||
"acknowledge": "I Understand"
|
"acknowledge": "I Understand"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"recent_comments": "Recent Comments",
|
||||||
|
"recommendations": "Recommendations",
|
||||||
|
"recommended_videos": "Recommended Videos",
|
||||||
|
"no_recommendations": "No recommendations found.",
|
||||||
"loading_activity": "Loading activity...",
|
"loading_activity": "Loading activity...",
|
||||||
"no_activity": "No recent activity.",
|
"no_activity": "No recent activity.",
|
||||||
"failed_to_load": "Failed to load.",
|
"failed_to_load": "Failed to load.",
|
||||||
@@ -472,7 +476,10 @@
|
|||||||
"view": "View",
|
"view": "View",
|
||||||
"read_more": "read more",
|
"read_more": "read more",
|
||||||
"see_less": "see less",
|
"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": {
|
"subscriptions": {
|
||||||
"title": "My Subscriptions",
|
"title": "My Subscriptions",
|
||||||
|
|||||||
@@ -458,6 +458,9 @@
|
|||||||
"acknowledge": "Ik Begrijp het"
|
"acknowledge": "Ik Begrijp het"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"recent_comments": "Recente reacties",
|
||||||
|
"recommendations": "Aanbevelingen",
|
||||||
|
"recommended_videos": "Aanbevolen video's",
|
||||||
"loading_activity": "Activiteit laden...",
|
"loading_activity": "Activiteit laden...",
|
||||||
"no_activity": "Geen recente activiteit.",
|
"no_activity": "Geen recente activiteit.",
|
||||||
"failed_to_load": "Laden mislukt.",
|
"failed_to_load": "Laden mislukt.",
|
||||||
@@ -466,7 +469,10 @@
|
|||||||
"view": "Bekijken",
|
"view": "Bekijken",
|
||||||
"read_more": "lees meer",
|
"read_more": "lees meer",
|
||||||
"see_less": "zie minder",
|
"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": {
|
"subscriptions": {
|
||||||
"title": "Mijn Abonnementen",
|
"title": "Mijn Abonnementen",
|
||||||
|
|||||||
@@ -459,6 +459,9 @@
|
|||||||
"acknowledge": "Ich verstehe"
|
"acknowledge": "Ich verstehe"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"recent_comments": "Frische Kommis",
|
||||||
|
"recommendations": "Zufallskram",
|
||||||
|
"recommended_videos": "Filmchen-Tipps",
|
||||||
"loading_activity": "Aktivität wird geladen...",
|
"loading_activity": "Aktivität wird geladen...",
|
||||||
"no_activity": "Noch keine Aktivität",
|
"no_activity": "Noch keine Aktivität",
|
||||||
"failed_to_load": "Ladung gescheitert.",
|
"failed_to_load": "Ladung gescheitert.",
|
||||||
@@ -467,7 +470,10 @@
|
|||||||
"view": "Ansehen",
|
"view": "Ansehen",
|
||||||
"read_more": "mehr sehen",
|
"read_more": "mehr sehen",
|
||||||
"see_less": "weniger 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": {
|
"subscriptions": {
|
||||||
"title": "Meine Abonnements",
|
"title": "Meine Abonnements",
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ const buildFeedFilters = async ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
const f0cklib = {
|
||||||
getItemPage: async ({
|
getItemPage: async ({
|
||||||
targetItemId,
|
targetItemId,
|
||||||
targetItemPinned,
|
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,
|
computeBaseMode,
|
||||||
getGlobalfilter,
|
getGlobalfilter,
|
||||||
processMentions,
|
processMentions,
|
||||||
@@ -2059,4 +2528,6 @@ export default {
|
|||||||
clearCountCache: () => countCache.clear()
|
clearCountCache: () => countCache.clear()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default f0cklib;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -708,6 +708,103 @@ export default router => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.get(/\/recommendations(?:\/(?<type>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) => {
|
group.get(/\/orakel\/user$/, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const now = ~~(Date.now() / 1000);
|
const now = ~~(Date.now() / 1000);
|
||||||
@@ -1201,6 +1298,7 @@ export default router => {
|
|||||||
where user_id = ${+req.session.id}
|
where user_id = ${+req.session.id}
|
||||||
and item_id = ${+postid}
|
and item_id = ${+postid}
|
||||||
`;
|
`;
|
||||||
|
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: -5.0 }).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
// add fav — ON CONFLICT DO NOTHING guards against rapid double-taps
|
// add fav — ON CONFLICT DO NOTHING guards against rapid double-taps
|
||||||
await db`
|
await db`
|
||||||
@@ -1210,6 +1308,7 @@ export default router => {
|
|||||||
}, 'item_id', 'user_id')}
|
}, 'item_id', 'user_id')}
|
||||||
on conflict do nothing
|
on conflict do nothing
|
||||||
`;
|
`;
|
||||||
|
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: +postid, scoreDelta: 5.0 }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const favs = await db`
|
const favs = await db`
|
||||||
|
|||||||
@@ -120,12 +120,26 @@
|
|||||||
@if(!private_society || session)
|
@if(!private_society || session)
|
||||||
<div class="global-sidebar-right">
|
<div class="global-sidebar-right">
|
||||||
<div class="sidebar-activity">
|
<div class="sidebar-activity">
|
||||||
<div id="sidebar-activity-container" class="sidebar-comments-list">
|
<div class="sidebar-tabs">
|
||||||
|
<button type="button" class="sidebar-tab active" data-tab="comments" title="{{ t('sidebar.recent_comments') }}" aria-label="{{ t('sidebar.recent_comments') }}">
|
||||||
|
<i class="fa-solid fa-comments"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="sidebar-tab" data-tab="recommendations" title="{{ t('sidebar.recommendations') }}" aria-label="{{ t('sidebar.recommendations') }}">
|
||||||
|
<i class="fa-solid fa-compass"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sidebar-activity-container" class="sidebar-comments-list sidebar-tab-content active" data-tab-content="comments">
|
||||||
<div class="sidebar-loading-state">
|
<div class="sidebar-loading-state">
|
||||||
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||||
<span>{{ t('sidebar.loading_activity') }}</span>
|
<span>{{ t('sidebar.loading_activity') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="sidebar-recommendations-container" class="sidebar-recommendations-list sidebar-tab-content" data-tab-content="recommendations" style="display: none;">
|
||||||
|
<div class="sidebar-loading-state">
|
||||||
|
<i class="fa-solid fa-circle-notch fa-spin"></i>
|
||||||
|
<span>{{ t('sidebar.loading_recommendations') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="global-sidebar-right-footer">
|
<div class="global-sidebar-right-footer">
|
||||||
<a href="/ranking">{{ t('footer.ranking') }}</a>
|
<a href="/ranking">{{ t('footer.ranking') }}</a>
|
||||||
@@ -454,6 +468,10 @@
|
|||||||
sidebar_failed_to_load: "{{ t('sidebar.failed_to_load') }}",
|
sidebar_failed_to_load: "{{ t('sidebar.failed_to_load') }}",
|
||||||
sidebar_loading_more: "{{ t('sidebar.loading_more') }}",
|
sidebar_loading_more: "{{ t('sidebar.loading_more') }}",
|
||||||
sidebar_end_of_activity: "{{ t('sidebar.end_of_activity') }}",
|
sidebar_end_of_activity: "{{ t('sidebar.end_of_activity') }}",
|
||||||
|
sidebar_recommendations: "{{ t('sidebar.recommendations') }}",
|
||||||
|
sidebar_loading_recommendations: "{{ t('sidebar.loading_recommendations') }}",
|
||||||
|
sidebar_no_recommendations: "{{ t('sidebar.no_recommendations') }}",
|
||||||
|
sidebar_refresh_recommendations: "{{ t('sidebar.refresh_recommendations') }}",
|
||||||
select_file: "{{ t('upload_btn.select_file') }}",
|
select_file: "{{ t('upload_btn.select_file') }}",
|
||||||
enter_url: "{{ t('upload_btn.enter_url') }}",
|
enter_url: "{{ t('upload_btn.enter_url') }}",
|
||||||
tags_required: "{{ t('upload_btn.tags_required') }}",
|
tags_required: "{{ t('upload_btn.tags_required') }}",
|
||||||
|
|||||||
@@ -29,7 +29,9 @@
|
|||||||
<label class="infobox-card-label"><i class="fa-solid fa-eye"></i> Visibility</label>
|
<label class="infobox-card-label"><i class="fa-solid fa-eye"></i> Visibility</label>
|
||||||
<div class="infobox-card-value-row">
|
<div class="infobox-card-value-row">
|
||||||
<span id="info-visibility-label" class="visibility-badge vis-{{ item.visibility || 0 }}">
|
<span id="info-visibility-label" class="visibility-badge vis-{{ item.visibility || 0 }}">
|
||||||
@if(item.visibility === 2)
|
@if(item.visibility === 3)
|
||||||
|
<i class="fa-solid fa-ban" style="color: var(--color-danger, #ff4444);"></i> Unavailable (451)
|
||||||
|
@elseif(item.visibility === 2)
|
||||||
<i class="fa-solid fa-lock" style="color: var(--color-danger, #ff4444);"></i> Private
|
<i class="fa-solid fa-lock" style="color: var(--color-danger, #ff4444);"></i> Private
|
||||||
@elseif(item.visibility === 1)
|
@elseif(item.visibility === 1)
|
||||||
<i class="fa-solid fa-link" style="color: var(--color-warning, #ffbb33);"></i> Unlisted
|
<i class="fa-solid fa-link" style="color: var(--color-warning, #ffbb33);"></i> Unlisted
|
||||||
|
|||||||
Reference in New Issue
Block a user