This commit is contained in:
2026-09-13 21:55:23 +02:00
parent 399f2be456
commit ba5bc18b98
32 changed files with 1739 additions and 270 deletions
+267 -26
View File
@@ -394,13 +394,20 @@ const buildFeedFilters = async ({
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
const conditions = terms.map(term => {
return db`and items.id in (
return db`and (items.id in (
select ta.item_id from tags_assign ta
join tags t on t.id = ta.tag_id
join "user" u on u.id = ta.user_id
where t.normalized like '%' || slugify(${term}) || '%'
and u.user ilike ${tagger}
)`;
) or items.id in (
select ai.item_id from album_items ai
join album_items_tags_assign aita on aita.album_item_id = ai.id
join tags t on t.id = aita.tag_id
join "user" u on u.id = aita.user_id
where t.normalized like '%' || slugify(${term}) || '%'
and u.user ilike ${tagger}
))`;
});
tagFilter = db`${conditions}`;
}
@@ -408,17 +415,29 @@ const buildFeedFilters = async ({
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
if (isStrict) {
tagFilter = db`and items.id in (
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}
)`;
) or items.id in (
select ai.item_id
from album_items ai
join album_items_tags_assign aita on aita.album_item_id = ai.id
join tags t on t.id = aita.tag_id
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ai.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}) || '%')`;
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}) || '%'
) or items.id in (
select ai.item_id from album_items ai join album_items_tags_assign aita on aita.album_item_id = ai.id join tags t on t.id = aita.tag_id where t.normalized like '%' || slugify(${term}) || '%'
))`;
});
tagFilter = db`${conditions}`;
}
@@ -802,6 +821,73 @@ const f0cklib = {
row.is_audio = !!(row.mime && row.mime.startsWith('audio/'));
}
if (tag && !isTitleSearch && rows.some(r => r.is_album)) {
const albumIds = rows.filter(r => r.is_album).map(r => r.id);
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0 && albumIds.length > 0) {
try {
const matchingSubs = await db`
SELECT
ai.item_id,
ai.id as subf0ck_id,
ai.slug as subf0ck_slug,
ai.dest as subf0ck_dest,
ai.mime as subf0ck_mime,
ai.order_index
FROM album_items ai
JOIN album_items_tags_assign aita ON aita.album_item_id = ai.id
JOIN tags t ON t.id = aita.tag_id
${tagger ? db`JOIN "user" u ON u.id = aita.user_id` : db``}
WHERE ai.item_id = ANY(${albumIds}::int[])
${tagger ? db`AND u.user ILIKE ${tagger}` : db``}
AND (${isStrict
? db`t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))`
: db`${terms.map(term => db`t.normalized LIKE '%' || slugify(${term}) || '%'`).reduce((a, b) => db`${a} OR ${b}`)}`
})
ORDER BY ai.item_id, ai.order_index ASC
`;
const matchMap = new Map();
for (const ms of matchingSubs) {
if (!matchMap.has(ms.item_id)) {
matchMap.set(ms.item_id, {
first: ms,
matchingIds: new Set([ms.subf0ck_id])
});
} else {
matchMap.get(ms.item_id).matchingIds.add(ms.subf0ck_id);
}
}
for (const row of rows) {
if (matchMap.has(row.id)) {
const info = matchMap.get(row.id);
const ms = info.first;
row.target_subf0ck_slug = ms.subf0ck_slug || ms.subf0ck_id;
if (ms.subf0ck_dest) {
const subBase = ms.subf0ck_dest.replace(/\.[^.]+$/, '');
row.thumb = `/t/${subBase}.webp`;
row.matching_sub_thumb = `/t/${subBase}.webp`;
row.matching_sub_dest = ms.subf0ck_dest;
row.dest = ms.subf0ck_dest;
}
if (ms.subf0ck_mime) {
row.matching_sub_mime = ms.subf0ck_mime;
row.mime = ms.subf0ck_mime;
}
const mCount = info.matchingIds.size;
row.album_count = mCount;
if (mCount <= 1) {
row.is_album = false;
}
}
}
} catch (err) {
console.warn('[FEED] Error resolving matching subf0cks for tag search:', err.message);
}
}
}
// Dynamic thumb sizing: applies to the main feed including mime/rating filters.
// Only per-user profiles, tag searches, halls, and favorites disable it.
const isMainFeed = cfg.websrv.enable_dynamic_thumbs
@@ -864,7 +950,7 @@ const f0cklib = {
view_mode: fav ? 'favs' : 'uploads'
};
},
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang, ids } = {}) => {
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang, ids, subf0ck } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
@@ -920,7 +1006,7 @@ const f0cklib = {
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
let itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
let requestedSubf0ckSlug = null;
let requestedSubf0ckSlug = subf0ck || null;
if (!isNumeric) {
const itemRow = await db`SELECT id FROM items WHERE slug = ${String(rawIdOrSlug)} LIMIT 1`;
@@ -1299,20 +1385,58 @@ const f0cklib = {
}
const coverartUrl = hasCoverart
? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp`
: `/s/img/music.webp`;
: null;
let album = [];
let effectiveItemTags = tags;
if (actitem.is_album) {
try {
const albumRows = await db`
let albumRows = await db`
SELECT id, dest, mime, size, width, height, order_index, slug, checksum
FROM album_items
WHERE item_id = ${itemid}
ORDER BY order_index ASC
`;
if (albumRows.length > 0) {
const itemIds = albumRows.map(r => r.id);
const subTagsRows = await db`
SELECT aita.album_item_id, t.id, t.tag, t.normalized
FROM album_items_tags_assign aita
JOIN tags t ON t.id = aita.tag_id
WHERE aita.album_item_id IN ${db(itemIds)}
ORDER BY (case when t.id = 1 then 0 when t.id = 2 then 1 when t.normalized = 'nsfl' then 2 else 3 end) asc, t.id asc
`.catch(() => []);
const tagsBySubId = new Map();
for (const st of subTagsRows) {
if (!tagsBySubId.has(st.album_item_id)) tagsBySubId.set(st.album_item_id, []);
st.badge = lib.getBadge(st);
tagsBySubId.get(st.album_item_id).push(st);
}
if (tag && !isTitleSearch) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0 && tagsBySubId.size > 0) {
const matching = albumRows.filter((r, idx) => {
let st = tagsBySubId.get(r.id) || [];
if (st.length === 0 && (r.order_index === 0 || idx === 0)) {
st = tags;
}
if (isStrict) {
return strictParams.every(sp => st.some(t => t.normalized === sp));
}
return terms.some(term => {
const slugTerm = lib.slugify(term);
return st.some(t => t.normalized && t.normalized.includes(slugTerm));
});
});
if (matching.length > 0) {
albumRows = matching;
}
}
}
album = await Promise.all(albumRows.map(async (r, idx) => {
const order = r.order_index !== undefined && r.order_index !== null ? r.order_index : idx;
const order = idx;
const subSlug = r.slug || r.id;
const subBase = r.dest.replace(/\.[^.]+$/, '');
const isAudio = (r.mime || '').startsWith('audio/');
@@ -1363,11 +1487,16 @@ const f0cklib = {
subThumb = subCover;
}
} else {
subCover = '/s/img/audio.webp';
subThumb = '/s/img/audio.webp';
subCover = null;
subThumb = null;
}
}
let subTags = tagsBySubId.get(r.id) || [];
if (subTags.length === 0 && (r.order_index === 0 || idx === 0)) {
subTags = tags;
}
return {
id: r.id,
slug: r.slug,
@@ -1386,11 +1515,49 @@ const f0cklib = {
is_video: (r.mime || '').startsWith('video/'),
is_audio: isAudio,
is_image: (r.mime || '').startsWith('image/'),
has_coverart: isAudio ? (subCover && subCover !== '/s/img/audio.webp') : false,
has_coverart: isAudio ? !!subCover : false,
coverart: isAudio ? subCover : null,
thumb: subThumb
thumb: subThumb,
tags: subTags
};
}));
let reqIdx = 0;
if (requestedSubf0ckSlug && album.length > 0) {
const found = album.findIndex(s => String(s.slug || '') === String(requestedSubf0ckSlug) || String(s.subf0ck_id || '') === String(requestedSubf0ckSlug) || String(s.id) === String(requestedSubf0ckSlug));
if (found !== -1) {
reqIdx = found;
}
}
if (album.length > 0 && album[reqIdx]) {
album.forEach((s, i) => { s.is_first = (i === reqIdx); });
const targetSub = album[reqIdx];
if (requestedSubf0ckSlug || tag) {
requestedSubf0ckSlug = targetSub.slug || targetSub.id;
} else {
requestedSubf0ckSlug = null;
}
actitem.dest = targetSub.filename || targetSub.dest.replace(/^\/b\//, '');
actitem.mime = targetSub.mime;
actitem.width = targetSub.width;
actitem.height = targetSub.height;
if (targetSub.size) {
actitem.size = targetSub.size;
}
if (targetSub.coverart) {
actitem.coverart = targetSub.coverart;
}
if (targetSub.tags && targetSub.tags.length > 0) {
effectiveItemTags = targetSub.tags;
if (!effectiveItemTags.some(t => t.id === 1 || t.id === 2 || t.normalized === 'nsfl')) {
const ratingTag = tags.find(t => t.id === 1 || t.id === 2 || t.normalized === 'nsfl');
if (ratingTag) {
effectiveItemTags = [ratingTag, ...effectiveItemTags];
}
}
}
}
}
} catch (err) {
console.error('[GETF0CK] Failed to fetch album items:', err.message);
@@ -1400,10 +1567,10 @@ const f0cklib = {
const duration = Date.now() - startTime;
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Fetch complete in ${duration}ms`);
const isNsfl = cfg.enable_nsfl && tags.some(t => t.id == nsfl_id);
const isNsfw = tags.some(t => t.id == 2);
const isSfw = tags.some(t => t.id == 1);
const isTagged = tags.length > 0;
const isNsfl = cfg.enable_nsfl && effectiveItemTags.some(t => t.id == nsfl_id);
const isNsfw = effectiveItemTags.some(t => t.id == 2);
const isSfw = effectiveItemTags.some(t => t.id == 1);
const isTagged = effectiveItemTags.length > 0;
const isUntagged = !isSfw && !isNsfw && !isNsfl;
// Guest rating restriction check for public uploads (visibility === 0)
if (!session && !isOwnerOrAdmin && (actitem.visibility || 0) === 0) {
@@ -1506,6 +1673,8 @@ const f0cklib = {
return `${ratingLabel}${titlePart} · uploaded by ${actitem.username}`;
})(),
coverart: coverartUrl,
has_coverart: !!hasCoverart,
is_audio: (actitem.mime || '').startsWith('audio/'),
dest: (() => {
if (actitem.mime !== 'video/youtube') return `${cfg.websrv.paths.images}/${actitem.dest}`;
if (actitem.dest && actitem.dest.startsWith('yt:')) return actitem.dest;
@@ -1522,7 +1691,7 @@ const f0cklib = {
timefull: new Date(actitem.stamp * 1e3).toISOString()
},
favorites: favorites,
tags: tags,
tags: effectiveItemTags,
halls: itemHalls,
user_halls: userHallsForItem,
is_nsfw: isNsfw,
@@ -2898,22 +3067,36 @@ const f0cklib = {
const isStrict = !!strict || (tag && tag.includes(','));
let tagConditions;
if (isStrict) {
tagConditions = terms.length > 0 ? [db`and items.id in (
tagConditions = terms.length > 0 ? [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}
)`] : [];
) or items.id in (
select ai.item_id
from album_items ai
join album_items_tags_assign aita on aita.album_item_id = ai.id
join tags t on t.id = aita.tag_id
where t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))
group by ai.item_id
having count(distinct t.normalized) = ${terms.length}
))` ] : [];
} else {
tagConditions = terms.map(term => {
return db`and items.id in (
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}) || '%'
)`;
) or items.id in (
select ai.item_id
from album_items ai
join album_items_tags_assign aita on aita.album_item_id = ai.id
join tags t on t.id = aita.tag_id
where t.normalized like '%' || slugify(${term}) || '%'
))`;
});
}
@@ -2975,7 +3158,7 @@ const f0cklib = {
const rows = await db`
WITH matched_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
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, items.is_album
FROM items
WHERE items.active = true
AND (items.is_deleted IS NOT TRUE)
@@ -3009,6 +3192,59 @@ const f0cklib = {
ORDER BY ${sortOrderMi}
`;
if (rows.some(r => r.is_album)) {
const albumIds = rows.filter(r => r.is_album).map(r => r.id);
if (terms.length > 0 && albumIds.length > 0) {
try {
const matchingSubs = await db`
SELECT
ai.item_id,
ai.id as subf0ck_id,
ai.slug as subf0ck_slug,
ai.dest as subf0ck_dest,
ai.mime as subf0ck_mime,
ai.order_index
FROM album_items ai
JOIN album_items_tags_assign aita ON aita.album_item_id = ai.id
JOIN tags t ON t.id = aita.tag_id
WHERE ai.item_id = ANY(${albumIds}::int[])
AND (${isStrict
? db`t.normalized = ANY(ARRAY(SELECT slugify(x) FROM unnest(${terms}::text[]) AS x))`
: db`${terms.map(term => db`t.normalized LIKE '%' || slugify(${term}) || '%'`).reduce((a, b) => db`${a} OR ${b}`)}`
})
ORDER BY ai.item_id, ai.order_index ASC
`;
const matchMap = new Map();
for (const ms of matchingSubs) {
if (!matchMap.has(ms.item_id)) {
matchMap.set(ms.item_id, ms);
}
}
for (const row of rows) {
if (matchMap.has(row.id)) {
const ms = matchMap.get(row.id);
row.target_subf0ck_slug = ms.subf0ck_slug || ms.subf0ck_id;
if (ms.subf0ck_dest) {
const subBase = ms.subf0ck_dest.replace(/\.[^.]+$/, '');
row.thumb = `/t/${subBase}.webp`;
row.matching_sub_thumb = `/t/${subBase}.webp`;
row.matching_sub_dest = ms.subf0ck_dest;
row.dest = ms.subf0ck_dest;
}
if (ms.subf0ck_mime) {
row.matching_sub_mime = ms.subf0ck_mime;
row.mime = ms.subf0ck_mime;
}
}
}
} catch (err) {
console.warn('[TAG-FEED] Error resolving matching subf0cks for tag feed:', err.message);
}
}
}
const items = rows.map(r => {
const meta = xdScoreMeta(r.xd_score);
const tagId = r.rating_tag_id;
@@ -3017,8 +3253,13 @@ const f0cklib = {
id: r.id,
title: r.title || null,
slug: r.slug || null,
mime: r.mime,
dest: r.dest,
mime: r.matching_sub_mime || r.mime,
dest: r.matching_sub_dest || r.dest,
thumb: r.thumb || `/t/${r.id}.webp`,
matching_sub_dest: r.matching_sub_dest || null,
matching_sub_mime: r.matching_sub_mime || null,
matching_sub_thumb: r.matching_sub_thumb || null,
target_subf0ck_slug: r.target_subf0ck_slug || null,
username: r.username,
display_name: r.display_name || r.username,
username_color: r.username_color || null,