f0ck algos
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user