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