huo
This commit is contained in:
@@ -3812,7 +3812,8 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.f0ck-tuner-subtab-btn {
|
||||
.f0ck-tuner-subtab-btn,
|
||||
.f0ck-tuner-mode-btn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -3830,12 +3831,14 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.f0ck-tuner-subtab-btn:hover {
|
||||
.f0ck-tuner-subtab-btn:hover,
|
||||
.f0ck-tuner-mode-btn:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.f0ck-tuner-subtab-btn.active {
|
||||
.f0ck-tuner-subtab-btn.active,
|
||||
.f0ck-tuner-mode-btn.active {
|
||||
color: #000;
|
||||
background: var(--accent, #99ff00);
|
||||
font-weight: 700;
|
||||
|
||||
+616
-153
File diff suppressed because it is too large
Load Diff
@@ -1615,8 +1615,13 @@
|
||||
}
|
||||
}
|
||||
} else if (!matchedCard && identifiers.length > 0 && currentTag && !isReCenteringFeed && !tagFeedLoading) {
|
||||
// Active item belongs to current tag context but is not in currently rendered slice
|
||||
// (e.g. after pressing Random in tag). Center feed around this active item!
|
||||
// Active item belongs to current tag context but is not in currently rendered slice.
|
||||
// If this was a pool-based random navigation, skip reload — keep existing items visible.
|
||||
if (window._isPoolRandomNav) {
|
||||
window._isPoolRandomNav = false;
|
||||
return;
|
||||
}
|
||||
// Non-pool navigation (next/prev): center feed around the active item
|
||||
isReCenteringFeed = true;
|
||||
loadTagFeed(true, identifiers[0]).finally(() => {
|
||||
isReCenteringFeed = false;
|
||||
@@ -2030,6 +2035,17 @@
|
||||
|
||||
// Reload recommendations or sync tag feed when user triggers Random (#random, #nav-random, or 'r' key)
|
||||
const handleRandomAction = () => {
|
||||
// If pool-based random is active, the context hasn't changed — skip reload
|
||||
if (window._randomPool && window._randomPool.items && window._randomPool.items.length > 1) {
|
||||
// Context is stable (same tag/hall/user) — just highlight the new item in tag feed
|
||||
const tag = getCurrentTag();
|
||||
if (tag && !userManuallySelectedNonTagTab) {
|
||||
// Refresh highlight in tag feed without re-fetching
|
||||
setTimeout(() => highlightActiveTagCard(true), 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Fallback: server-side random may change context, so reload
|
||||
recommendationsLoaded = false;
|
||||
const tag = getCurrentTag();
|
||||
if (tag) {
|
||||
|
||||
+221
-70
@@ -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 [];
|
||||
|
||||
@@ -6,6 +6,234 @@ import { createI18n } from "../i18n.mjs";
|
||||
import { isAnonymizeSession } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
// ── Merged random + item load: single request instead of two ────────────
|
||||
router.get(/^\/ajax\/item\/random/, async (req, res) => {
|
||||
const tAjaxStart = Date.now();
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
query = parsedUrl.query;
|
||||
} else {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const isGuest = !req.session || !req.session.user;
|
||||
const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode);
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = isGuest ? ['sfw'] : ((reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null));
|
||||
|
||||
const tag = query.tag || null;
|
||||
const hall = query.hall || null;
|
||||
const user = query.user || null;
|
||||
const userHall = query.userHall || null;
|
||||
const userHallOwner = query.userHallOwner || null;
|
||||
const isFav = query.fav === 'true';
|
||||
const isStrict = query.strict === '1';
|
||||
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
|
||||
const mime = (typeof query.mime !== 'undefined') ? (query.mime || null) : (cookieMime || null);
|
||||
|
||||
// Resolve random item ID
|
||||
const randomData = await f0cklib.getRandom({
|
||||
user, tag, hall, userHall, userHallOwner, mime,
|
||||
fav: isFav,
|
||||
mode: reqMode,
|
||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||
strict: isStrict,
|
||||
session: req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin
|
||||
});
|
||||
const tRandom = Date.now();
|
||||
if (!randomData || !randomData.itemid) {
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: '', error: true, success: false, message: 'No items found' })
|
||||
});
|
||||
}
|
||||
|
||||
const itemid = String(randomData.itemid);
|
||||
|
||||
// Build context URL for the resolved item
|
||||
let contextUrl = `/${itemid}`;
|
||||
if (tag) contextUrl = `/tag/${encodeURIComponent(tag)}/${itemid}`;
|
||||
if (hall) contextUrl = `/h/${encodeURIComponent(hall)}/${itemid}`;
|
||||
if (userHall && userHallOwner) {
|
||||
contextUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
|
||||
} else if (user) {
|
||||
contextUrl = isFav
|
||||
? `/user/${encodeURIComponent(user)}/favs/${itemid}`
|
||||
: `/user/${encodeURIComponent(user)}/${itemid}`;
|
||||
} else if (isFav) {
|
||||
contextUrl = `/favs/${itemid}`;
|
||||
}
|
||||
if (mime) {
|
||||
contextUrl = contextUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
|
||||
}
|
||||
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX-RANDOM] Resolved random item ${itemid} in ${Date.now() - tAjaxStart}ms`);
|
||||
|
||||
// Now run the full item load pipeline (same as /ajax/item/:id)
|
||||
const bypassFilter = !!(query.bypass === '1');
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: itemid,
|
||||
mode: reqMode,
|
||||
ratings: ratingsArr,
|
||||
bypass_filter: bypassFilter,
|
||||
session: req.session,
|
||||
url: contextUrl,
|
||||
user: user,
|
||||
tag: tag,
|
||||
hall: hall,
|
||||
userHall: userHall,
|
||||
userHallOwner: userHallOwner,
|
||||
mime: mime,
|
||||
fav: isFav,
|
||||
ids: null,
|
||||
random: true,
|
||||
strict: isStrict || req.session?.strict_mode,
|
||||
explicitStrict: isStrict,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
user_id: req.session?.id,
|
||||
subf0ck: query.subf0ck || null
|
||||
});
|
||||
const tAjaxFetch = Date.now();
|
||||
|
||||
if (!data.success) {
|
||||
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
|
||||
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' };
|
||||
const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null;
|
||||
const errorHtml = tpl.render('error-partial', {
|
||||
message: tErr('error.post_not_visible'),
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
item_id: data.item?.id || itemid,
|
||||
item_slug: data.item?.slug || null,
|
||||
error_mode_label: errorModeLabel,
|
||||
error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `<strong>${errorModeLabel}</strong>` }) : null,
|
||||
error_filter_hint_link: tErr('error.filter_hint_link'),
|
||||
error_see_anyways: tErr('error.see_anyways')
|
||||
}, req);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: errorHtml, pagination: '', error: true })
|
||||
});
|
||||
}
|
||||
|
||||
// xD Score + comments — parallelize subscription + comments fetch
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
if (req.session?.id) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
const [sub, commentsForScore] = await Promise.all([
|
||||
req.session ? f0cklib.getSubscriptionStatus(req.session.id, itemid) : false,
|
||||
f0cklib.getComments(itemid, 'old', false)
|
||||
]);
|
||||
data.isSubscribed = sub;
|
||||
const xdScore = f0cklib.computeXdScore(commentsForScore);
|
||||
const xdMeta = f0cklib.xdScoreMeta(xdScore);
|
||||
data.item.xd_score = xdScore;
|
||||
data.item.xd_tier = xdMeta.tier;
|
||||
data.item.xd_label = xdMeta.label;
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
} else {
|
||||
data.isSubscribed = false;
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
data.item.xd_score = 0;
|
||||
data.item.xd_tier = 0;
|
||||
data.item.xd_label = '';
|
||||
}
|
||||
const tAjaxAux = Date.now();
|
||||
|
||||
// Session + template vars
|
||||
data.session = req.session ? { ...req.session } : false;
|
||||
data.url = { pathname: contextUrl };
|
||||
data.fullscreen = req.cookies.fullscreen || 0;
|
||||
data.hidePagination = true;
|
||||
|
||||
// Precompute hall display data
|
||||
if (data.item && data.item.halls && data.item.halls.length) {
|
||||
const currentHallSlug = data.tmp && data.tmp.hall
|
||||
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
|
||||
: null;
|
||||
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
|
||||
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
|
||||
} else if (data.item) {
|
||||
data.item.primaryHall = null;
|
||||
data.item.otherHalls = [];
|
||||
}
|
||||
|
||||
// Precomputed template booleans
|
||||
if (data.item) {
|
||||
const session = data.session;
|
||||
const item = data.item;
|
||||
if (isAnonymizeSession(req.session)) {
|
||||
if (item.src) item.src = null;
|
||||
item.username = 'anonymous';
|
||||
item.author_banner_file = null;
|
||||
item.author_banner_position = null;
|
||||
item.author_banner_size = null;
|
||||
item.author_avatar = null;
|
||||
item.author_avatar_file = null;
|
||||
item.author_color = null;
|
||||
item.author_description = null;
|
||||
item.author_display_name = null;
|
||||
item.author_id = null;
|
||||
if (data.uploader) {
|
||||
data.uploader.name = 'anonymous';
|
||||
data.uploader.id = null;
|
||||
data.uploader.color = null;
|
||||
}
|
||||
if (Array.isArray(item.favorites)) {
|
||||
item.favorites = item.favorites.map(f => {
|
||||
const isSelf = session && session.id && f.user_id && Number(f.user_id) === Number(session.id);
|
||||
if (isSelf) return f;
|
||||
return { user_id: null, user: 'anonymous', login: 'anonymous', display_name: 'Anonymous', avatar: null, avatar_file: null, username_color: null, hide_fav_badge: f.hide_fav_badge, is_anon: true };
|
||||
});
|
||||
}
|
||||
}
|
||||
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
|
||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
||||
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
|
||||
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
||||
data.item_username_lower = (item.username || '').toLowerCase();
|
||||
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
||||
data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
|
||||
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||
data.item_has_dimensions = !!(item.width && item.height);
|
||||
}
|
||||
|
||||
// Render
|
||||
const itemHtml = tpl.render('ajax-item', data, req);
|
||||
const paginationHtml = tpl.render('snippets/pagination', data, req);
|
||||
const tAjaxRender = Date.now();
|
||||
|
||||
// Detailed timing breakdown
|
||||
console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`);
|
||||
|
||||
res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
html: itemHtml,
|
||||
pagination: paginationHtml,
|
||||
title: data.title,
|
||||
id: itemid,
|
||||
slug: data.item?.slug || null,
|
||||
page: null,
|
||||
is_random: true
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
|
||||
const tAjaxStart = Date.now();
|
||||
let query = {};
|
||||
|
||||
@@ -800,27 +800,13 @@ export default router => {
|
||||
});
|
||||
}
|
||||
|
||||
// Run item fetch + page lookup in parallel — saves one sequential DB round-trip
|
||||
const [rows, itemPage] = await Promise.all([
|
||||
db`
|
||||
SELECT *
|
||||
FROM "items"
|
||||
WHERE id = ${data.itemid} AND active = true
|
||||
LIMIT 1
|
||||
`,
|
||||
f0cklib.getItemPage({
|
||||
targetItemId: data.itemid,
|
||||
user, tag, hall, userHall, userHallOwner, mime,
|
||||
fav: isFav,
|
||||
mode,
|
||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||
strict: isStrict,
|
||||
session: req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin
|
||||
}).catch(() => 1)
|
||||
]);
|
||||
// Skip getItemPage for random — page number is meaningless and it's expensive
|
||||
const rows = await db`
|
||||
SELECT *
|
||||
FROM "items"
|
||||
WHERE id = ${data.itemid} AND active = true
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const item = rows[0];
|
||||
|
||||
@@ -852,11 +838,49 @@ export default router => {
|
||||
dest: relativeDest,
|
||||
url: directUrl,
|
||||
direct_url: directUrl,
|
||||
page: itemPage
|
||||
page: 1
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group.get('/random-pool', async (req, res) => {
|
||||
const user = req.url.qs?.user || null;
|
||||
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
|
||||
const mime = (typeof req.url.qs?.mime !== 'undefined')
|
||||
? (req.url.qs.mime || null)
|
||||
: (cookieMime || null);
|
||||
const tag = req.url.qs?.tag || null;
|
||||
const hall = req.url.qs?.hall || null;
|
||||
const userHall = req.url.qs?.userHall || null;
|
||||
const userHallOwner = req.url.qs?.userHallOwner || null;
|
||||
const isFav = req.url.qs?.fav === 'true';
|
||||
const isStrict = req.url.qs?.strict === '1';
|
||||
const mode = req.mode ?? 0;
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
|
||||
const tStart = Date.now();
|
||||
const pool = await f0cklib.getRandomPool({
|
||||
user,
|
||||
tag,
|
||||
hall,
|
||||
userHall,
|
||||
userHallOwner,
|
||||
mime,
|
||||
fav: isFav,
|
||||
mode,
|
||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||
strict: isStrict,
|
||||
session: req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin
|
||||
});
|
||||
console.log(`[RANDOM-POOL] ${pool.total} items in ${Date.now() - tStart}ms (mode=${mode} tag=${tag} hall=${hall} user=${user})`);
|
||||
|
||||
return res.json(pool);
|
||||
});
|
||||
|
||||
group.get(/\/recommendations(?:\/(?<type>videos|all))?$/, async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(+(req.url.qs?.limit || 20), 50);
|
||||
|
||||
@@ -67,7 +67,11 @@ export default (router, tpl) => {
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: data.message,
|
||||
tmp: null
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
error_filter_hint: null,
|
||||
error_filter_hint_link: null,
|
||||
error_see_anyways: null
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<span>{{ t('error.label') }}</span>
|
||||
<code>{{ message }}</code>
|
||||
</div>
|
||||
@if(error_filter_hint)
|
||||
@if(typeof error_filter_hint !== 'undefined' && error_filter_hint)
|
||||
<div class="_error_filter_hint">
|
||||
<div class="_error_filter_hint_text">{{ error_filter_hint }}</div>
|
||||
<a href="#" data-action="open-filter-modal" class="_error_filter_hint_link">{{ error_filter_hint_link }}</a>
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
<span>{{ t('error.label') }}</span>
|
||||
<code>{{ message }}</code>
|
||||
</div>
|
||||
@if(error_filter_hint)
|
||||
@if(typeof error_filter_hint !== 'undefined' && error_filter_hint)
|
||||
<div class="_error_filter_hint">
|
||||
<div class="_error_filter_hint_text">{{ error_filter_hint }}</div>
|
||||
<a href="#" data-action="open-filter-modal" class="_error_filter_hint_link">{{ error_filter_hint_link }}</a>
|
||||
|
||||
Reference in New Issue
Block a user