f0ck algos

This commit is contained in:
2026-09-12 04:25:49 +02:00
parent b55a17dc85
commit 2913b03d50
12 changed files with 1756 additions and 22 deletions
+7 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+472 -1
View File
@@ -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;
+99
View File
@@ -707,6 +707,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) => {
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`