This commit is contained in:
2026-09-16 17:40:52 +02:00
parent 93914ad02a
commit c8cfe07c70
9 changed files with 1142 additions and 253 deletions
+221 -70
View File
@@ -1236,7 +1236,7 @@ const f0cklib = {
// 1. Fetch the main item
const items = await db`
select distinct on (items.id)
select
items.*,
items.username as username,
uo.username_color as author_color,
@@ -1252,9 +1252,7 @@ const f0cklib = {
${user_id ? db`coalesce(uvv.view_count, 0) as my_views` : db`0 as my_views`}
from items
left join favorites on favorites.item_id = items.id
left join "user" fav_u on fav_u.id = favorites.user_id
left join "user" author_u on author_u."user" = items.username or author_u.login = items.username
left join "user" author_u on author_u."user" = items.username
left join "user_options" uo on uo.user_id = author_u.id
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
where
@@ -1408,24 +1406,65 @@ const f0cklib = {
};
const runTimings = startTime;
// 2. Neighbor queries — skip useless ones in random mode
const [nextItem, prevItem, startItem, endItem, cheatItems] = await Promise.all([
random ? optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by random()`) : optimizedBaseQuery(db`and items.id > ${itemid}`, db`order by items.id asc`),
random ? optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by random()`) : optimizedBaseQuery(db`and items.id < ${itemid}`, db`order by items.id desc`),
optimizedBaseQuery(db``, db`order by items.id asc`),
optimizedBaseQuery(db``, db`order by items.id desc`),
// Cheat items - try to get a few neighbors. Simplified: just get some newer ones
optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by abs(items.id - ${itemid}) asc`, 7)
random
? optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by random()`)
: optimizedBaseQuery(db`and items.id > ${itemid}`, db`order by items.id asc`),
random
? Promise.resolve(null) // reuse nextItem for prev in random mode
: optimizedBaseQuery(db`and items.id < ${itemid}`, db`order by items.id desc`),
random ? Promise.resolve([]) : optimizedBaseQuery(db``, db`order by items.id asc`),
random ? Promise.resolve([]) : optimizedBaseQuery(db``, db`order by items.id desc`),
random ? Promise.resolve([]) : optimizedBaseQuery(db`and items.id != ${itemid}`, db`order by abs(items.id - ${itemid}) asc`, 7)
]);
console.log(`[GETF0CK_OPT] Neighbor queries finished in ${Date.now() - runTimings}ms`);
console.log(`[GETF0CK_OPT] Neighbor queries finished in ${Date.now() - runTimings}ms (random=${!!random})`);
// For random: prev reuses nextItem result (both are random, no point running 2 ORDER BY random())
const effectivePrev = random ? nextItem : prevItem;
// Cheat array should include current item and neighbors, sorted
const cheat = [itemid, ...cheatItems.map(i => i.id)].sort((a, b) => a - b);
const tags = await lib.getTags(itemid, session);
const itemHalls = await db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`;
const userHallsForItem = user_id
? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}`
: [];
// 3. Metadata queries — sequential to avoid pool saturation
const repostBaseChecksum = actitem.checksum
? (actitem.checksum.includes('_bypass_') ? actitem.checksum.split('_bypass_')[0] : actitem.checksum)
: null;
const hasBypass = actitem.checksum && actitem.checksum.includes('_bypass_');
const [tags, itemHalls, userHallsForItem, favorites, repostRows, phashMatches] = await Promise.all([
lib.getTags(itemid, session),
db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`,
user_id
? db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}`
: [],
db`
select "favorites".user_id, "user".user, "user".login, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge, "anon_identities".fingerprint as anon_fingerprint
from "favorites"
left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id
left join "anon_identities" on "anon_identities".user_id = "favorites".user_id
where "favorites".item_id = ${itemid}
`,
repostBaseChecksum
? db`
SELECT id, slug, username, stamp FROM items
WHERE active = true
AND id != ${itemid}
AND ${hasBypass
? db`(checksum = ${repostBaseChecksum} OR checksum LIKE ${repostBaseChecksum + '_bypass_%'})`
: db`checksum LIKE ${repostBaseChecksum + '_bypass_%'}`
}
ORDER BY id ASC
`
: [],
(actitem.phash && actitem.phash !== 'ERROR' && actitem.phash !== 'MISSING')
? queue.findallrepostphash(actitem.phash, itemid).catch(() => [])
: []
]);
console.log(`[GETF0CK_OPT] All queries finished in ${Date.now() - runTimings}ms`);
const link = lib.genLink({ user, tag, hall: (hall && typeof hall === 'object') ? hall.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: '', strict: false });
// Override link for title searches — pagination must use the /tag/title:... prefix
if (isTitleSearch && titleQuery) {
@@ -1442,14 +1481,6 @@ const f0cklib = {
link.path = '';
link.suffix = '';
}
const favorites = await db`
select "favorites".user_id, "user".user, "user".login, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge, "anon_identities".fingerprint as anon_fingerprint
from "favorites"
left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id
left join "anon_identities" on "anon_identities".user_id = "favorites".user_id
where "favorites".item_id = ${itemid}
`;
for (const f of favorites) {
if (f.anon_fingerprint || f.user === 'anonymous' || (typeof f.user === 'string' && f.user.startsWith('anon_'))) {
@@ -1464,47 +1495,17 @@ const f0cklib = {
}
}
// Detect reposts: items uploaded with bypass_duplicate_check have checksum = `{hash}_bypass_{ts}`
// Find all items (including this one) that share the same base checksum.
let repostItems = [];
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
const baseChecksum = actitem.checksum.split('_bypass_')[0];
const repostRows = await db`
SELECT id, slug, username, stamp FROM items
WHERE active = true
AND id != ${itemid}
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
ORDER BY id ASC
`;
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
} else if (actitem.checksum) {
// Even without bypass, check if other bypass-entries exist with this same hash
const baseChecksum = actitem.checksum;
const repostRows = await db`
SELECT id, slug, username, stamp FROM items
WHERE active = true
AND id != ${itemid}
AND checksum LIKE ${baseChecksum + '_bypass_%'}
ORDER BY id ASC
`;
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
}
// Also find visually-similar items via phash, merging with checksum results
if (actitem.phash && actitem.phash !== 'ERROR' && actitem.phash !== 'MISSING') {
try {
const phashMatches = await queue.findallrepostphash(actitem.phash, itemid);
const existingIds = new Set(repostItems.map(r => r.id));
for (const pm of phashMatches) {
if (!existingIds.has(pm.id)) {
repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
existingIds.add(pm.id);
}
// Merge checksum + phash repost results
let repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
if (phashMatches.length > 0) {
const existingIds = new Set(repostItems.map(r => r.id));
for (const pm of phashMatches) {
if (!existingIds.has(pm.id)) {
repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
existingIds.add(pm.id);
}
repostItems.sort((a, b) => a.id - b.id);
} catch (e) {
console.error('[GETF0CK] phash repost lookup failed:', e.message);
}
repostItems.sort((a, b) => a.id - b.id);
}
let hasCoverart = false;
@@ -1844,7 +1845,7 @@ const f0cklib = {
let anonBlocked = false;
if (isNsfw && !allowedModes.includes('nsfw')) anonBlocked = true;
else if (isNsfl && !allowedModes.includes('nsfl')) anonBlocked = true;
else if (isUntagged && !allowedModes.includes('untagged')) anonBlocked = true;
else if (isUntagged && !allowedModes.includes('untagged') && Number(mode ?? 0) !== 2) anonBlocked = true;
else if (isSfw && !allowedModes.includes('sfw')) anonBlocked = true;
if (anonBlocked) {
@@ -1867,6 +1868,7 @@ const f0cklib = {
// Mode-mismatch visibility check:
// Mode 0=sfw, 1=nsfw, 2=untagged, 3=all
const userMode = Number(mode ?? 0);
if (userMode === 2) console.log(`[MODE2-DEBUG] item=${itemid} isTagged=${isTagged} isUntagged=${isUntagged} isSfw=${isSfw} isNsfw=${isNsfw} isNsfl=${isNsfl} tags=${effectiveItemTags.map(t => t.id + ':' + t.normalized).join(',')}`);
if (!bypass_filter) {
let modeBlocked = false;
const untaggedDisallowed = (!session && !cfg.websrv.public_untagged) || (isAnonSession(session) && !getAnonAllowedModes().includes('untagged'));
@@ -1877,7 +1879,7 @@ const f0cklib = {
if (userMode === 0 && (isNsfw || isNsfl || (isUntagged && untaggedDisallowed))) modeBlocked = true; // SFW mode, item is NSFW or NSFL
else if (userMode === 1 && !isNsfw) modeBlocked = true; // NSFW mode, item is not NSFW
else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL
else if (userMode === 2 && (isTagged || untaggedDisallowed)) modeBlocked = true; // Untagged mode, item has tags
else if (userMode === 2 && !isUntagged) modeBlocked = true; // Unrated mode, item has a rating (sfw/nsfw/nsfl)
}
if (modeBlocked) {
@@ -1993,7 +1995,7 @@ const f0cklib = {
end: (getEnableItemSlugs() && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
start: (getEnableItemSlugs() && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
next: (getEnableItemSlugs() && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
prev: (getEnableItemSlugs() && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
prev: (getEnableItemSlugs() && effectivePrev[0]?.slug) ? effectivePrev[0].slug : (effectivePrev[0]?.id || null),
page: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id,
cheat: cheat
},
@@ -2114,8 +2116,6 @@ const f0cklib = {
select
items.id
from items
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
where
${db.unsafe(modequery)}
and items.active = true
@@ -2126,7 +2126,6 @@ const f0cklib = {
${mimeSQL}
${(!session || isAnonSession(session)) && getGlobalfilter(session) ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter(session))}))` : 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, tags.tag
order by random()
limit 1
`;
@@ -2176,7 +2175,8 @@ const f0cklib = {
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible
const useTagIdOpt = tagId && !mimeParts.includes('audio');
const nsfpIds = cfg.nsfp || [];
const checkFilter = (!session || isAnonSession(session)) && nsfpIds.length > 0;
// Only apply nsfp filter for SFW/All modes — when user explicitly selected NSFW/NSFL, don't exclude those items
const checkFilter = (!session || isAnonSession(session)) && nsfpIds.length > 0 && (mode === 0 || mode === 3 || mode === undefined || mode === null);
// Use a single uniform query with ORDER BY random()
// For 30k-100k items, this is performant enough and much more reliable than seeking.
@@ -2211,6 +2211,157 @@ const f0cklib = {
itemid: item[0].id
};
},
/**
* getRandomPool — returns all candidate IDs/slugs for client-side random selection.
* Mirrors getRandom() filter logic exactly (mode, ratings, strict, exclusions, visibility, mime, etc.)
* Caps at 500 items; uses ORDER BY random() sampling for larger sets.
*/
getRandomPool: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, user_id, is_admin, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return { items: [], total: 0 };
}
}
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const hall = rawHall || null;
const _decodedTag = rawTag ? decodeURIComponent(rawTag) : '';
const isTitleSearch = _decodedTag.startsWith('title:');
const titleQuery = isTitleSearch ? _decodedTag.substring(6).trim() : null;
const tag = isTitleSearch ? null : lib.parseTag(rawTag ?? null);
const mime = (rawMime ?? "");
const userHallSlug = rawUserHall || null;
const userHallOwner = rawUserHallOwner || null;
let userHallId = null;
if (userHallSlug && userHallOwner) {
const uhRows = await db`
SELECT uh.id FROM user_halls uh
JOIN "user" u ON u.id = uh.user_id
WHERE u."user" ILIKE ${userHallOwner} AND uh.slug = ${userHallSlug}
LIMIT 1
`;
userHallId = uhRows[0]?.id || null;
}
const { mimeParts, mimeSQL } = resolveMimeSQL(mime, session);
const excludedTags = session && exclude ? (exclude || []) : [];
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0;
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
const modequery = computeBaseMode(mode, ratings, session);
// Common filter fragments
const globalFilter = (!session || isAnonSession(session)) && getGlobalfilter(session) ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter(session))}))` : db``;
const excludeFilter = excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``;
let rows;
if (isTitleSearch && titleQuery) {
rows = await db`
SELECT items.id, items.slug FROM items
WHERE ${db.unsafe(modequery)}
AND items.active = true AND coalesce(items.visibility, 0) = 0
AND items.title ILIKE ${'%' + titleQuery + '%'} AND items.title IS NOT NULL
${mimeSQL} ${globalFilter} ${excludeFilter}
ORDER BY items.id
`;
} else if (fav && user) {
rows = await db`
SELECT DISTINCT items.id, items.slug FROM favorites
INNER JOIN items ON favorites.item_id = items.id
INNER JOIN "user" ON "user".id = favorites.user_id
WHERE ${db.unsafe(modequery)}
AND "user".user ILIKE ${user}
AND items.active = true AND coalesce(items.visibility, 0) = 0
${mimeSQL} ${globalFilter}
ORDER BY items.id
`;
} else if (user || tag) {
let tagFilter = db``;
if (tag) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
if (isStrict) {
tagFilter = db`and items.id in (
select ta.item_id
from tags_assign ta
join tags t on t.id = ta.tag_id
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ta.item_id
having count(distinct t.normalized) = ${terms.length}
)`;
} else {
const conditions = terms.map(term => {
return db`and items.id in (select ta.item_id from tags_assign ta join tags t on t.id = ta.tag_id where t.normalized like '%' || slugify(${term}) || '%')`;
});
tagFilter = db`${conditions}`;
}
}
}
rows = await db`
SELECT items.id, items.slug FROM items
WHERE ${db.unsafe(modequery)}
AND items.active = true AND coalesce(items.visibility, 0) = 0
${tagFilter}
${user ? db`and items.username ilike ${user}` : db``}
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
${mimeSQL} ${globalFilter} ${excludeFilter}
ORDER BY items.id
`;
} else if (hall) {
rows = await db`
SELECT items.id, items.slug FROM items
JOIN halls_assign ha ON ha.item_id = items.id
JOIN halls h ON h.id = ha.hall_id
WHERE ${db.unsafe(modequery)}
AND h.slug = ${hall}
AND items.active = true AND coalesce(items.visibility, 0) = 0
${mimeSQL} ${globalFilter} ${excludeFilter}
ORDER BY items.id
`;
} else if (userHallId) {
rows = await db`
SELECT items.id, items.slug FROM items
JOIN user_halls_assign uha ON uha.item_id = items.id
WHERE ${db.unsafe(modequery)}
AND uha.hall_id = ${userHallId}
AND items.active = true AND coalesce(items.visibility, 0) = 0
${mimeSQL} ${excludeFilter}
ORDER BY items.id
`;
} else {
// Global pool — use tag optimization where possible
const globalModeQuery = modequery;
const tagId = session && !isAnonSession(session) && !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1))
: null;
const useTagIdOpt = tagId && !mimeParts.includes('audio');
const nsfpIds = cfg.nsfp || [];
const checkFilter = (!session || isAnonSession(session)) && nsfpIds.length > 0 && (mode === 0 || mode === 3 || mode === undefined || mode === null);
rows = await db`
SELECT items.id, items.slug FROM items
${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``}
${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``}
WHERE items.active = true AND coalesce(items.visibility, 0) = 0
${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
${excludeFilter}
${!useTagIdOpt ? db`AND ${db.unsafe(globalModeQuery)}` : db``}
ORDER BY items.id LIMIT 10000
`;
}
const items = rows.map(r => r.slug || String(r.id));
return { items, total: items.length, sampled: items.length >= 10000 };
},
getComments: async (itemId, sort = 'new', process = true) => {
const numericId = await resolveNumericItemId(itemId);
if (!numericId) return [];