'use strict'; // Anchors are the titles a user has shown taste for; their tags steer the wider AniList pool. const ANCHOR_WEIGHTS = { flame: 5, good: 3, interested: 4, maybe: 2, read: 3, owned: 1 }; const MAX_ANCHORS = 15; const MIN_TAG_RANK = 60; const MAX_TOP_TAGS = 3; function anchorsFor({ user, db, library, greatest }) { if (!user?.settings?.discoveryEnabled) return []; const byId = new Map(); const add = (anilistId, source, title) => { if (!Number.isSafeInteger(anilistId) || anilistId <= 0) return; const weight = ANCHOR_WEIGHTS[source]; const prior = byId.get(anilistId); if (!prior || weight > prior.weight) byId.set(anilistId, { anilistId, weight, source, title: title ?? null }); }; const ratings = db?.greatestRatings?.[user.name] || {}; for (const entry of greatest?.entries || []) { const vote = ratings[entry.id]; if (vote === 'flame' || vote === 'good') add(Number(entry.metadata?.id), vote, entry.title); } const feedback = db?.discoveryFeedback?.[user.name] || {}; for (const [key, value] of Object.entries(feedback)) { const match = /^anilist:(\d+)$/.exec(key); if (match && (value === 'interested' || value === 'maybe')) add(Number(match[1]), value, null); } const progress = db?.progress?.[user.name] || {}; const reading = Boolean(user.settings.discoveryReadingActivity); for (const manga of library || []) { const id = Number(manga.meta?.['x-anilist']?.id); if (!id) continue; const chapters = progress[manga.id]; const done = reading && chapters && Object.values(chapters).some(chapter => chapter?.done); add(id, done ? 'read' : 'owned', manga.title); } return [...byId.values()] .sort((a, b) => b.weight - a.weight || a.anilistId - b.anilistId) .slice(0, MAX_ANCHORS); } function topTagsFor(anchors, recordOf, { mature = false } = {}) { const scores = new Map(); for (const anchor of anchors) { const record = recordOf(anchor.anilistId); for (const tag of Array.isArray(record?.tags) ? record.tags : []) { // A tag without a rank is no evidence of taste, so it must not pass the rank filter. if (!tag || typeof tag !== 'object' || typeof tag.name !== 'string') continue; if (!(tag.rank >= MIN_TAG_RANK) || (tag.isAdult && !mature)) continue; const entry = scores.get(tag.name) || { name: tag.name, kind: tag.kind, score: 0 }; entry.score += anchor.weight; scores.set(tag.name, entry); } } return [...scores.values()] .sort((a, b) => b.score - a.score || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) .slice(0, MAX_TOP_TAGS) .map(({ name, kind }) => ({ name, kind })); } module.exports = { ANCHOR_WEIGHTS, MAX_ANCHORS, anchorsFor, topTagsFor };