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
+57 -9
View File
@@ -284,17 +284,65 @@ export default new class {
return false;
};
async getTags(itemid, session = null) {
async getTags(itemid, session = null, subf0ckId = null) {
const isAnonymized = isAnonymizeSession(session);
const hasSession = !isAnonymized && !!(session && !session.is_anon && (typeof session === 'object' ? (session.id || session.user) : session));
const tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
from "tags_assign"
left join "tags" on "tags".id = "tags_assign".tag_id
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
where "tags_assign".item_id = ${+itemid}
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
`;
let albumItemId = null;
if (subf0ckId !== null && subf0ckId !== undefined && subf0ckId !== '') {
if (Number.isInteger(+subf0ckId) && +subf0ckId > 0) {
const row = await db`
SELECT id FROM album_items
WHERE (id = ${+subf0ckId} OR (item_id = ${+itemid} AND order_index = ${+subf0ckId}))
AND item_id = ${+itemid}
LIMIT 1
`;
albumItemId = row?.[0]?.id || null;
}
if (!albumItemId) {
const row = await db`
SELECT id FROM album_items
WHERE slug = ${String(subf0ckId)}
AND item_id = ${+itemid}
LIMIT 1
`;
albumItemId = row?.[0]?.id || null;
}
}
let tags;
if (albumItemId) {
tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
from "album_items_tags_assign"
left join "tags" on "tags".id = "album_items_tags_assign".tag_id
${hasSession ? db`left join "user" on "user".id = "album_items_tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
where "album_items_tags_assign".album_item_id = ${+albumItemId}
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
`;
if (tags.length === 0) {
const isOrderZero = await db`SELECT order_index FROM album_items WHERE id = ${albumItemId} LIMIT 1`;
if (isOrderZero?.[0]?.order_index === 0) {
tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
from "tags_assign"
left join "tags" on "tags".id = "tags_assign".tag_id
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
where "tags_assign".item_id = ${+itemid}
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
`;
}
}
} else {
tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
from "tags_assign"
left join "tags" on "tags".id = "tags_assign".tag_id
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
where "tags_assign".item_id = ${+itemid}
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
`;
}
let hasRating = false;
const cleanTags = [];
const excludedTagIds = (session && Array.isArray(session.excluded_tags)) ? session.excluded_tags : [];
+3 -1
View File
@@ -849,6 +849,8 @@
"add_more": "Weitere Subf0cks hinzufügen",
"drop_hint": "Wähle oder ziehe mehrere Dateien (Subf0cks) hierher, um ein Album zu erstellen",
"select_pictures": "Dateien für Album auswählen",
"min_pictures": "Mindestens 2 Subf0cks für ein Album erforderlich"
"min_pictures": "Mindestens 2 Subf0cks für ein Album erforderlich",
"subf0ck_tags": "Tags für diesen Subf0ck",
"subf0ck_tags_placeholder": "Tags für diesen Subf0ck (optional)"
}
}
+3 -1
View File
@@ -849,6 +849,8 @@
"add_more": "Add more subf0cks",
"drop_hint": "Select or drop multiple files (subf0cks) to create an Album",
"select_pictures": "Select files for album",
"min_pictures": "Add at least 2 subf0cks for an album"
"min_pictures": "Add at least 2 subf0cks for an album",
"subf0ck_tags": "Tags for this subf0ck",
"subf0ck_tags_placeholder": "Tags for this subf0ck (optional)"
}
}
+3 -1
View File
@@ -839,6 +839,8 @@
"add_more": "Meer afbeeldingen toevoegen",
"drop_hint": "Selecteer of sleep meerdere afbeeldingen om een album te maken",
"select_pictures": "Selecteer afbeeldingen voor album",
"min_pictures": "Voeg minimaal 2 afbeeldingen toe voor een album"
"min_pictures": "Voeg minimaal 2 afbeeldingen toe voor een album",
"subf0ck_tags": "Tags voor deze subf0ck",
"subf0ck_tags_placeholder": "Tags voor deze subf0ck (optioneel)"
}
}
+3 -1
View File
@@ -839,6 +839,8 @@
"add_more": "Weitere Bildnisse hinzufügen",
"drop_hint": "Wähle oder droppe mehrere Bilder für 1 Album",
"select_pictures": "Bildnisse fürs Album auswählen",
"min_pictures": "Mindestens 2 Bildnisse fürs Album nötig"
"min_pictures": "Mindestens 2 Bildnisse fürs Album nötig",
"subf0ck_tags": "Tags für dies Unterf0ck",
"subf0ck_tags_placeholder": "Tags für dies Unterf0ck (optional)"
}
}
+5 -5
View File
@@ -684,10 +684,10 @@ export default new class queue {
const ptSize = String(Math.round(thumbSize * 0.35));
try {
await this.spawn('magick', [
'-size', thumbSpec, 'radial-gradient:#334a15-#1e1e1e',
'(', '+clone', '-font', faFont, '-pointsize', ptSize, '-gravity', 'center', '-fill', 'rgba(153,255,0,0.5)', '-annotate', '0', '\uf001', '-blur', '0x12', ')',
'-size', thumbSpec, 'radial-gradient:#2c2c2c-#181818',
'(', '+clone', '-font', faFont, '-pointsize', ptSize, '-gravity', 'center', '-fill', 'rgba(255,255,255,0.3)', '-annotate', '0', '\uf001', '-blur', '0x12', ')',
'-composite',
'-font', faFont, '-pointsize', ptSize, '-gravity', 'center', '-fill', '#99ff00', '-annotate', '0', '\uf001',
'-font', faFont, '-pointsize', ptSize, '-gravity', 'center', '-fill', 'rgba(240,240,240,0.88)', '-annotate', '0', '\uf001',
targetFile
]);
return true;
@@ -698,8 +698,8 @@ export default new class queue {
return true;
} catch (copyErr) {
await this.spawn('magick', [
'-size', thumbSpec, 'radial-gradient:#334a15-#1e1e1e',
'-gravity', 'center', '-fill', '#99ff00', '-pointsize', '60', '-annotate', '0', '♪',
'-size', thumbSpec, 'radial-gradient:#2c2c2c-#181818',
'-gravity', 'center', '-fill', 'rgba(240,240,240,0.88)', '-pointsize', '60', '-annotate', '0', '♪',
targetFile
]).catch(() => {});
return false;
+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,
+2 -1
View File
@@ -59,7 +59,8 @@ export default (router, tpl) => {
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id
user_id: req.session?.id,
subf0ck: query.subf0ck || null
});
const tAjaxFetch = Date.now();
+59 -22
View File
@@ -1820,19 +1820,42 @@ export default router => {
let newRatingId;
let currentRatingId = null;
const subf0ck = req.body?.subf0ck_id || req.post?.subf0ck_id || req.url?.qs?.subf0ck_id || null;
let albumItemId = null;
if (subf0ck) {
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${itemid} AND order_index = ${+subf0ck})) AND item_id = ${itemid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
if (!albumItemId) {
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${itemid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
}
await db.begin(async sql => {
// Lock the item row exclusively so any concurrent rating update for this post MUST wait
await sql`SELECT id FROM items WHERE id = ${itemid} FOR UPDATE`;
const existingRating = await sql`
SELECT tag_id FROM tags_assign
WHERE item_id = ${itemid}
AND (tag_id IN (1, 2, ${nsfl_id}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
ORDER BY tag_id DESC
LIMIT 1
`;
if (albumItemId) {
const existingRating = await sql`
SELECT tag_id FROM album_items_tags_assign
WHERE album_item_id = ${albumItemId}
AND tag_id IN (1, 2, ${nsfl_id})
LIMIT 1
`;
currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
} else {
const existingRating = await sql`
SELECT tag_id FROM tags_assign
WHERE item_id = ${itemid}
AND (tag_id IN (1, 2, ${nsfl_id}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
ORDER BY tag_id DESC
LIMIT 1
`;
currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
}
currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
newRatingId = 1;
@@ -1851,19 +1874,32 @@ export default router => {
}
}
// Remove ALL existing rating tags for this item atomically
await sql`
DELETE FROM tags_assign
WHERE item_id = ${itemid}
AND (tag_id IN (1, 2, ${nsfl_id}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
// Insert new rating tag
if (newRatingId > 0) {
if (albumItemId) {
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
DELETE FROM album_items_tags_assign
WHERE album_item_id = ${albumItemId} AND tag_id IN (1, 2, ${nsfl_id})
`;
if (newRatingId > 0) {
await sql`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
VALUES (${albumItemId}, ${newRatingId}, ${req.session.id})
`;
}
} else {
// Remove ALL existing rating tags for this item atomically
await sql`
DELETE FROM tags_assign
WHERE item_id = ${itemid}
AND (tag_id IN (1, 2, ${nsfl_id}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
// Insert new rating tag
if (newRatingId > 0) {
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
`;
}
}
// Ensure blurred thumbnail exists
@@ -1875,15 +1911,16 @@ export default router => {
const newRating = newRatingId === 1 ? 'sfw' : (newRatingId === 2 ? 'nsfw' : 'nsfl');
const ratingLabels = { 1: 'sfw', 2: 'nsfw', [nsfl_id]: 'nsfl' };
const oldRating = currentRatingId ? ratingLabels[currentRatingId] : 'none';
audit.log(req.session.id, 'update_rating', 'item', itemid, { old: oldRating, new: newRating }).catch(() => {});
audit.log(req.session.id, 'update_rating', 'item', itemid, { old: oldRating, new: newRating, subf0ck_id: subf0ck }).catch(() => {});
// Notify that tags have changed
const freshTags = await lib.getTags(itemid);
db.notify('tags', JSON.stringify({ item_id: itemid, fresh: true, tags: freshTags })).catch(() => {});
const freshTags = await lib.getTags(itemid, req.session, subf0ck);
db.notify('tags', JSON.stringify({ item_id: itemid, subf0ck_id: subf0ck, fresh: true, tags: freshTags })).catch(() => {});
return res.json({
success: true,
itemid: itemid,
subf0ck_id: subf0ck,
rating: newRating,
tags: freshTags
});
+173 -62
View File
@@ -272,9 +272,10 @@ export default router => {
});
}
const subf0ck = req.url?.qs?.subf0ck_id || req.url?.qs?.subf0ck || null;
return res.json({
success: true,
tags: await lib.getTags(+req.params.postid, req.session)
tags: await lib.getTags(+req.params.postid, req.session, subf0ck)
});
});
@@ -309,6 +310,19 @@ export default router => {
});
}
const subf0ck = req.post?.subf0ck_id || req.body?.subf0ck_id || req.post?.subf0ck || req.body?.subf0ck || null;
let albumItemId = null;
if (subf0ck) {
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${postid} AND order_index = ${+subf0ck})) AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
if (!albumItemId) {
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
}
try {
let tagid = (await db`
select id
@@ -326,37 +340,51 @@ export default router => {
`)[0].id;
}
await db`
insert into "tags_assign" ${db({
tag_id: +tagid,
item_id: +postid,
user_id: +req.session.id
})
}
`;
if (albumItemId) {
await db`
INSERT INTO "album_items_tags_assign" (album_item_id, tag_id, user_id)
VALUES (${+albumItemId}, ${+tagid}, ${+req.session.id})
ON CONFLICT DO NOTHING
`;
await db`
INSERT INTO "tags_assign" (item_id, tag_id, user_id)
VALUES (${+postid}, ${+tagid}, ${+req.session.id})
ON CONFLICT DO NOTHING
`;
} else {
await db`
insert into "tags_assign" ${db({
tag_id: +tagid,
item_id: +postid,
user_id: +req.session.id
})
}
`;
}
} catch (err) {
const isDuplicate = err.code === '23505' || err.constraint?.includes('tags_assign');
return res.json({
success: false,
msg: isDuplicate ? 'Tag already exists' : 'Failed to add tag',
tags: await lib.getTags(postid)
tags: await lib.getTags(postid, req.session, subf0ck)
});
}
const freshTags = await lib.getTags(postid);
const freshTags = await lib.getTags(postid, req.session, subf0ck);
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'tag',
targetId: postid,
details: { tag: tagname }
details: { tag: tagname, subf0ck_id: subf0ck }
});
}
console.log(`[API] Notifying 'tags' for item ${postid} with ${freshTags.length} tags`);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
console.log(`[API] Notifying 'tags' for item ${postid} (subf0ck: ${subf0ck || 'none'}) with ${freshTags.length} tags`);
await db.notify('tags', JSON.stringify({ item_id: postid, subf0ck_id: subf0ck, fresh: true, tags: freshTags }));
return res.json({
success: true,
postid: postid,
subf0ck_id: subf0ck,
tag: tagname,
tags: freshTags
});
@@ -391,44 +419,90 @@ export default router => {
let nextTagId;
let ratingTagId = 0;
const subf0ck = req.body?.subf0ck_id || req.post?.subf0ck_id || req.url?.qs?.subf0ck_id || null;
let albumItemId = null;
if (subf0ck) {
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${postid} AND order_index = ${+subf0ck})) AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
if (!albumItemId) {
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
}
try {
await db.begin(async sql => {
// Lock the item row exclusively
await sql`SELECT id FROM items WHERE id = ${postid} FOR UPDATE`;
const existingRating = await sql`
SELECT tag_id FROM tags_assign
WHERE item_id = ${postid}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
ORDER BY tag_id DESC
LIMIT 1
`;
ratingTagId = existingRating.length > 0 ? existingRating[0].tag_id : 0;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
nextTagId = 1;
} else if (reqRating === 'nsfw') {
nextTagId = 2;
} else if (reqRating === 'nsfl') {
nextTagId = nsflId;
} else {
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
// Remove ALL existing rating tags for this item atomically
await sql`
DELETE FROM tags_assign
WHERE item_id = ${postid}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
if (nextTagId > 0) {
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${postid}, ${nextTagId}, ${+req.session.id})
if (albumItemId) {
const existingRating = await sql`
SELECT tag_id FROM album_items_tags_assign
WHERE album_item_id = ${albumItemId}
AND tag_id IN (1, 2, ${nsflId})
LIMIT 1
`;
ratingTagId = existingRating.length > 0 ? existingRating[0].tag_id : 0;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
nextTagId = 1;
} else if (reqRating === 'nsfw') {
nextTagId = 2;
} else if (reqRating === 'nsfl') {
nextTagId = nsflId;
} else {
const cycleIdx = cycle.indexOf(ratingTagId);
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
await sql`
DELETE FROM album_items_tags_assign
WHERE album_item_id = ${albumItemId}
AND tag_id IN (1, 2, ${nsflId})
`;
if (nextTagId > 0) {
await sql`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
VALUES (${albumItemId}, ${nextTagId}, ${+req.session.id})
`;
}
} else {
const existingRating = await sql`
SELECT tag_id FROM tags_assign
WHERE item_id = ${postid}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
ORDER BY tag_id DESC
LIMIT 1
`;
ratingTagId = existingRating.length > 0 ? existingRating[0].tag_id : 0;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
nextTagId = 1;
} else if (reqRating === 'nsfw') {
nextTagId = 2;
} else if (reqRating === 'nsfl') {
nextTagId = nsflId;
} else {
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
// Remove ALL existing rating tags for this item atomically
await sql`
DELETE FROM tags_assign
WHERE item_id = ${postid}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
if (nextTagId > 0) {
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${postid}, ${nextTagId}, ${+req.session.id})
`;
}
}
// Automatically generate/verify blurred thumbnail on cycle
@@ -534,46 +608,83 @@ export default router => {
const postid = +req.params.postid;
const tagname = decodeURIComponent(req.params.tagname);
const tags = await lib.getTags(postid);
const subf0ck = req.post?.subf0ck_id || req.body?.subf0ck_id || req.url?.qs?.subf0ck_id || req.url?.qs?.subf0ck || null;
let albumItemId = null;
if (subf0ck) {
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${postid} AND order_index = ${+subf0ck})) AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
if (!albumItemId) {
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
}
const tagid = tags.filter(t => t.tag === tagname)[0]?.id ?? null;
const tags = await lib.getTags(postid, req.session, subf0ck);
const tagid = tags.filter(t => t.tag === tagname || t.normalized === tagname.toLowerCase())[0]?.id ?? null;
if (!tagid) {
return res.json({
success: false,
msg: 'tag is not assigned',
tags: await lib.getTags(postid)
tags: await lib.getTags(postid, req.session, subf0ck)
});
}
let q = await db`
delete from "tags_assign"
where tag_id = ${+tagid}
and item_id = ${+postid}
`;
const reply = !!q;
let reply = false;
if (albumItemId) {
const q = await db`
DELETE FROM album_items_tags_assign
WHERE album_item_id = ${+albumItemId}
AND tag_id = ${+tagid}
`;
reply = !!q;
const otherUses = await db`
SELECT 1 FROM album_items_tags_assign aita
JOIN album_items ai ON ai.id = aita.album_item_id
WHERE ai.item_id = ${postid} AND aita.tag_id = ${+tagid}
LIMIT 1
`;
if (otherUses.length === 0) {
await db`
DELETE FROM tags_assign
WHERE item_id = ${postid} AND tag_id = ${+tagid}
`.catch(() => {});
}
} else {
let q = await db`
delete from "tags_assign"
where tag_id = ${+tagid}
and item_id = ${+postid}
`;
reply = !!q;
}
if (reply) {
const reason = req.post.reason || req.url.qs.reason || 'No reason provided';
await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, reason });
const reason = req.post?.reason || req.url?.qs?.reason || 'No reason provided';
await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, subf0ck_id: subf0ck, reason });
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'delete_tag',
targetId: postid,
details: { tag: tagname, reason }
details: { tag: tagname, subf0ck_id: subf0ck, reason }
});
}
}
const freshTags = await lib.getTags(postid);
console.log(`[API] Notifying 'tags' (delete) for item ${postid}`);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
const freshTags = await lib.getTags(postid, req.session, subf0ck);
console.log(`[API] Notifying 'tags' (delete) for item ${postid} (subf0ck: ${subf0ck || 'none'})`);
await db.notify('tags', JSON.stringify({ item_id: postid, subf0ck_id: subf0ck, fresh: true, tags: freshTags }));
return res.json({
success: reply,
tagid,
subf0ck_id: subf0ck,
tags: freshTags
})
});
});
});
+2 -1
View File
@@ -285,7 +285,8 @@ export default (router, tpl) => {
random: req.cookies.random_mode === '1',
minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)),
lang: req.lang,
tagger: req.url.qs?.tagger || null
tagger: req.url.qs?.tagger || null,
subf0ck: req.query?.subf0ck || req.url.qs?.subf0ck || null
});
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
+11
View File
@@ -274,6 +274,17 @@ export default (router, tpl) => {
if (f0ck[0].mime.startsWith('audio')) {
await moveSafe(p.ca, caDst);
}
if (f0ck[0].is_album) {
try {
const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`;
for (const sub of subItems) {
const subBase = sub.dest.replace(/\.[^.]+$/, '');
await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest));
await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`));
}
} catch (_) {}
}
break;
} catch (e) { }
}
+48
View File
@@ -1,6 +1,8 @@
import cfg from "../config.mjs";
import fs from "fs/promises";
import path from "path";
import db from "../sql.mjs";
import queue from "../queue.mjs";
export default (router, tpl) => {
router.static({
@@ -61,6 +63,52 @@ export default (router, tpl) => {
return res.end(content);
}
} catch (_) {}
// Dynamic generation or fallback for subf0cks / albums
const base = file.replace(/\.[^.]+$/, '').replace(/_blur$/, '');
try {
const subRow = await db`SELECT id, item_id, dest, mime FROM album_items WHERE dest LIKE ${base + '.%'} LIMIT 1`;
if (subRow.length > 0) {
const sub = subRow[0];
const srcFile = path.join(cfg.paths.b, sub.dest);
const thumbDest = path.join(cfg.paths.t, `${base}.webp`);
const srcStat = await fs.stat(srcFile).catch(() => null);
if (srcStat && srcStat.size > 0) {
if (sub.mime.startsWith('image/')) {
await queue.spawn('magick', [srcFile + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', thumbDest]);
} else if (sub.mime.startsWith('video/')) {
await queue.spawn('ffmpegthumbnailer', ['-i', srcFile, '-s', '256', '-o', thumbDest]).catch(async () => {
await queue.spawn('ffmpeg', ['-y', '-ss', '00:00:01', '-i', srcFile, '-vframes', '1', '-vf', 'scale=256:256:force_original_aspect_ratio=increase,crop=256:256', thumbDest]);
});
}
const genStat = await fs.stat(thumbDest).catch(() => null);
if (genStat && genStat.size > 0) {
const content = await fs.readFile(thumbDest);
res.writeHead(200, {
'Content-Length': genStat.size,
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=3600'
});
return res.end(content);
}
}
// Fallback to parent album thumbnail if subf0ck media file is missing
const parentThumb = path.join(cfg.paths.t, `${sub.item_id}.webp`);
const pStat = await fs.stat(parentThumb).catch(() => null);
if (pStat && pStat.size > 0) {
const content = await fs.readFile(parentThumb);
res.writeHead(200, {
'Content-Length': pStat.size,
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=3600'
});
return res.end(content);
}
}
} catch (e) {
console.warn('[STATIC /t/] Dynamic subf0ck thumbnail generation error:', e.message);
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('404 - file not found.');
});
+11
View File
@@ -545,6 +545,17 @@ process.on('uncaughtException', err => {
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_item_id ON album_items(item_id, order_index ASC)`);
await runMigration(db`ALTER TABLE album_items ADD COLUMN IF NOT EXISTS slug character varying(60) DEFAULT NULL`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_slug ON album_items(slug)`);
await runMigration(db`
CREATE TABLE IF NOT EXISTS album_items_tags_assign (
album_item_id INTEGER NOT NULL REFERENCES album_items(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES "user"(id) ON DELETE SET DEFAULT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (album_item_id, tag_id)
)
`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_tag_id ON album_items_tags_assign(tag_id)`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_album_item_id ON album_items_tags_assign(album_item_id)`);
// Initial halls cache (only if halls are enabled)
if (cfg.websrv.halls_enabled !== false) {
+51 -1
View File
@@ -881,7 +881,7 @@ export const handleUpload = async (req, res, self) => {
const subSize = subFile.data.length;
const subItemSlug = lib.generateSlug(11);
await db`
const insertedSub = await db`
INSERT INTO album_items ${db({
item_id: itemid,
dest: subFilename,
@@ -894,7 +894,57 @@ export const handleUpload = async (req, res, self) => {
order_index: i,
slug: subItemSlug
}, 'item_id', 'dest', 'mime', 'size', 'checksum', 'phash', 'width', 'height', 'order_index', 'slug')}
RETURNING id
`;
const subItemId = insertedSub?.[0]?.id;
// Process any per-subf0ck tags passed in parts
const rawSubTags = parts[`subf0ck_tags_${i}`] || parts[`subtags_${i}`];
if (subItemId && rawSubTags) {
const parsedSubTags = String(rawSubTags)
.split(/[,;\n]+/)
.map(t => t.trim())
.filter(t => t.length > 0 && t.length <= 85 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
for (const sTag of parsedSubTags) {
try {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${sTag}) LIMIT 1`;
if (tagRow.length === 0) {
await db`INSERT INTO tags ${db({ tag: sTag }, 'tag')}`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${sTag}) LIMIT 1`;
}
if (tagRow.length > 0) {
const sTagId = tagRow[0].id;
await db`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
VALUES (${subItemId}, ${sTagId}, ${req.session.id})
ON CONFLICT DO NOTHING
`;
await db`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${itemid}, ${sTagId}, ${req.session.id})
ON CONFLICT DO NOTHING
`;
}
} catch (stErr) {
console.warn(`[UPLOAD] Error assigning tag "${sTag}" to subf0ck ${subItemId}:`, stErr.message);
}
}
} else if (subItemId && i === 0 && tags && tags.length > 0) {
// Backfill initial post tags to cover subf0ck
for (const sTag of tags) {
try {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${sTag}) LIMIT 1`;
if (tagRow.length > 0) {
await db`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
VALUES (${subItemId}, ${tagRow[0].id}, ${req.session.id})
ON CONFLICT DO NOTHING
`;
}
} catch (_) {}
}
}
// Generate thumbnail for album item filmstrip
try {