diff --git a/discovery/anchors.js b/discovery/anchors.js new file mode 100644 index 0000000..3e4bb03 --- /dev/null +++ b/discovery/anchors.js @@ -0,0 +1,63 @@ +'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 record?.tags || []) { + 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 }; diff --git a/test/discovery-anchors.test.js b/test/discovery-anchors.test.js new file mode 100644 index 0000000..b9569e8 --- /dev/null +++ b/test/discovery-anchors.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { anchorsFor, topTagsFor, ANCHOR_WEIGHTS, MAX_ANCHORS } = require('../discovery/anchors'); + +const user = (settings = { discoveryEnabled: true }) => ({ name: 'ann', settings }); +const manga = (id, anilist, title = `Manga ${id}`) => ({ id, title, meta: anilist ? { 'x-anilist': { id: anilist } } : {} }); + +test('anchors are empty without opt-in', () => { + const library = [manga('a', 1)]; + assert.deepEqual(anchorsFor({ user: user({}), db: {}, library, greatest: { entries: [] } }), []); + assert.deepEqual(anchorsFor({ user: user({ discoveryEnabled: false }), db: {}, library, greatest: { entries: [] } }), []); +}); + +test('weights, dedupe and cap', () => { + const greatest = { entries: [{ id: 'g1', title: 'Great One', metadata: { id: 7 } }, { id: 'g2', title: 'No Meta' }] }; + const db = { greatestRatings: { ann: { g1: 'good', g2: 'flame' } } }; + const anchors = anchorsFor({ user: user(), db, library: [manga('m7', 7, 'Owned Seven')], greatest }); + assert.deepEqual(anchors, [{ anilistId: 7, weight: 3, source: 'good', title: 'Great One' }]); + assert.equal(ANCHOR_WEIGHTS.flame, 5); + const many = Array.from({ length: 20 }, (_, i) => manga(`m${i}`, i + 1)); + const capped = anchorsFor({ user: user(), db: {}, library: many, greatest: { entries: [] } }); + assert.equal(capped.length, MAX_ANCHORS); + assert.equal(capped.length, 15); + assert.deepEqual(capped.map(a => a.anilistId), Array.from({ length: 15 }, (_, i) => i + 1)); + assert.equal(capped[0].source, 'owned'); +}); + +test('reading anchors need the reading opt-in', () => { + const library = [manga('m1', 1), manga('m2', 2)]; + const db = { progress: { ann: { m1: { c1: { done: true } }, m2: { c1: { done: false } } } } }; + const off = anchorsFor({ user: user(), db, library, greatest: { entries: [] } }); + assert.deepEqual(off.map(a => [a.anilistId, a.source]), [[1, 'owned'], [2, 'owned']]); + const on = anchorsFor({ user: user({ discoveryEnabled: true, discoveryReadingActivity: true }), db, library, greatest: { entries: [] } }); + assert.deepEqual(on.map(a => [a.anilistId, a.source, a.weight, a.title]), [[1, 'read', 3, 'Manga m1'], [2, 'owned', 1, 'Manga m2']]); +}); + +test('feedback keys parse anilist ids and ignore others', () => { + const db = { discoveryFeedback: { ann: { + 'anilist:10': 'interested', 'anilist:11': 'maybe', 'anilist:12': 'dismissed', 'anilist:13': 'hidden', + 'greatest:x': 'interested', 'anilist:abc': 'interested' } } }; + const anchors = anchorsFor({ user: user(), db, library: [], greatest: { entries: [] } }); + assert.deepEqual(anchors, [ + { anilistId: 10, weight: 4, source: 'interested', title: null }, + { anilistId: 11, weight: 2, source: 'maybe', title: null }]); +}); + +test('top tags weigh anchors, skip low rank and adult tags', () => { + const records = { + 1: { tags: [{ name: 'Action', rank: 100, kind: 'genre', isAdult: false }, { name: 'Ecchi', rank: 90, kind: 'tag', isAdult: true }, + { name: 'Faint', rank: 59, kind: 'tag', isAdult: false }] }, + 2: { tags: [{ name: 'Action', rank: 100, kind: 'genre', isAdult: false }, { name: 'Zeta', rank: 60, kind: 'tag', isAdult: false }, + { name: 'Alpha', rank: 80, kind: 'tag', isAdult: false }] } + }; + const anchors = [{ anilistId: 1, weight: 5 }, { anilistId: 2, weight: 3 }, { anilistId: 3, weight: 5 }]; + const recordOf = id => records[id] || null; + assert.deepEqual(topTagsFor(anchors, recordOf), [ + { name: 'Action', kind: 'genre' }, { name: 'Alpha', kind: 'tag' }, { name: 'Zeta', kind: 'tag' }]); + assert.deepEqual(topTagsFor(anchors, recordOf, { mature: true }), [ + { name: 'Action', kind: 'genre' }, { name: 'Ecchi', kind: 'tag' }, { name: 'Alpha', kind: 'tag' }]); +});