import Database from 'better-sqlite3' import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' import path from 'node:path' import { effectiveConfig, suggestConfig } from './settings.js' import { mapLidarrSearch } from './mapping.js' // Förslagsmotorn: tre batch-typer — // mix artister/album från bibliotek + historik + YT-tillägg // tracks enskilda låtar från senast gillat + mest spelat // singers sångare från de 10 senaste unika sångarna i gillat/spelat; // varje sångare får topplåtar + relaterade artister, filtrerade // mot det man redan har i biblioteket/gillalistan // Alla batcher cachas på disk (/data/suggestions.json). export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { const file = path.join(config.dataDir ?? '/data', 'suggestions.json') const cfgFor = (type) => suggestConfig(store.load())[type] function loadBatches() { try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return [] } } function saveBatch(batches, batch) { batches.unshift(batch) mkdirSync(path.dirname(file), { recursive: true }) writeFileSync(file, JSON.stringify(batches, null, 2)) return batch } // ---------- signal-insamling ---------- function ytRows(sql) { if (!existsSync(config.youtubarr.dbPath)) return [] try { const db = new Database(config.youtubarr.dbPath, { readonly: true, fileMustExist: true }) const rows = db.prepare(sql).all() db.close() return rows } catch { return [] } } function ytLiked(limit = 10) { let rows = ytRows( `SELECT ti.title, ti.artist_name_guess FROM youtubarr_trackitem ti JOIN youtubarr_playlist p ON p.id = ti.playlist_id WHERE p.playlist_id = 'LM' AND ti.blacklisted = 0 ORDER BY ti.id DESC LIMIT ${limit}`, ) if (rows.length === 0) { rows = ytRows( `SELECT ti.title, ti.artist_name_guess FROM youtubarr_trackitem ti WHERE ti.blacklisted = 0 ORDER BY ti.id DESC LIMIT ${limit}`, ) } return rows } async function jellyfinItems(sortBy, limit) { const eff = effectiveConfig(config, store.load()) if (!eff.jellyfin.apiKey || !eff.jellyfin.userId) return [] try { const url = `${eff.jellyfin.url}/Users/${eff.jellyfin.userId}/Items` + `?IncludeItemTypes=Audio&SortBy=${sortBy}&SortOrder=Descending` + `&Filters=IsPlayed&Recursive=true&Limit=${limit}` const res = await fetchImpl(url, { headers: { 'X-Emby-Token': eff.jellyfin.apiKey } }) if (!res.ok) return [] const js = await res.json() return (js.Items ?? []).map((i) => ({ name: i.Name, artist: (i.Artists ?? [])[0] ?? i.AlbumArtist ?? '?', })) } catch { return [] } } async function libraryArtists() { try { return (await lidarr.artists()).map((a) => a.artistName) } catch { return [] } } async function collectMixSignals(sources) { const signals = { bibliotek: [], nyligenSpelat: [], nyligenTillagt: [] } if (sources.bibliotek) signals.bibliotek = (await libraryArtists()).slice(0, 150) if (sources.spelat) { signals.nyligenSpelat = (await jellyfinItems('DatePlayed', 30)).map((i) => `${i.artist} – ${i.name}`) } if (sources.tillagt) { signals.nyligenTillagt = ytRows( `SELECT ti.title, ti.artist_name_guess, p.title AS playlist FROM youtubarr_trackitem ti LEFT JOIN youtubarr_playlist p ON p.id = ti.playlist_id WHERE ti.blacklisted = 0 ORDER BY ti.id DESC LIMIT 40`, ).map((r) => `${r.artist_name_guess || '?'} – ${r.title}${r.playlist ? ` [${r.playlist}]` : ''}`) } return signals } async function collectTrackSignals(sources) { const signals = { gillade: [], mestSpelade: [] } if (sources.gillade) { signals.gillade = ytLiked(10).map((r) => `${r.artist_name_guess || '?'} – ${r.title}`) } if (sources.mestSpelade) { signals.mestSpelade = (await jellyfinItems('PlayCount', 10)).map((i) => `${i.artist} – ${i.name}`) } return signals } async function collectGenreSignals(sources) { const seen = new Set() const artister = [] const add = (name) => { const key = (name ?? '').trim().toLowerCase() if (!key || key === '?' || seen.has(key)) return seen.add(key) artister.push(name.trim()) } if (sources.gillade) for (const r of ytLiked(40)) add(r.artist_name_guess) if (sources.spelat) for (const i of await jellyfinItems('DatePlayed', 40)) add(i.artist) return { artister: artister.slice(0, 10) } } // ---------- prompter ---------- function buildMixPrompt(signals, count) { return [ 'Du är en kunnig musikrekommendatör. Utifrån signalerna nedan, föreslå', `exakt ${count} artister eller album som användaren troligen skulle gilla men`, 'som INTE redan finns i biblioteket. Blanda gärna kända och smalare val.', 'Svara ENBART med en JSON-array i formatet:', '[{"type":"artist"|"album","name":"...","artist":"...","motivation":"..."}]', 'där "artist" bara sätts för album, och "motivation" är en kort svensk', 'mening om varför förslaget passar.', '', `Bibliotek (föreslå INTE dessa): ${signals.bibliotek.join(', ') || '(tomt)'}`, '', `Nyligen spelat: ${signals.nyligenSpelat.join('; ') || '(ingen data)'}`, '', `Nyligen tillagt/gillat på YouTube Music: ${signals.nyligenTillagt.join('; ') || '(ingen data)'}`, ].join('\n') } function buildTrackPrompt(signals, count) { return [ 'Du är en kunnig musikrekommendatör. Utifrån låtarna nedan, föreslå', `exakt ${count} ENSKILDA LÅTAR (inte album eller artister) som användaren`, 'troligen skulle gilla. Föreslå INTE låtar som redan finns i listorna.', 'Svara ENBART med en JSON-array i formatet:', '[{"name":"låttitel","artist":"artistnamn","motivation":"..."}]', 'där "motivation" är en kort svensk mening om varför låten passar.', '', `Senast gillade låtar (YouTube Music): ${signals.gillade.join('; ') || '(ingen data)'}`, '', `Mest spelade låtar (Jellyfin): ${signals.mestSpelade.join('; ') || '(ingen data)'}`, ].join('\n') } function buildGenresPrompt(signals, count, library) { return [ 'Du är en kunnig musikrekommendatör med fokus på GENRER/MUSIKSTILAR.', `Utifrån artisterna nedan, föreslå exakt ${count} musikgenrer eller`, 'subgenrer som användaren verkar gilla eller borde utforska — blanda', 'kärngenrer ur lyssningen med angränsande stilar som breddar.', 'Svara ENBART med en JSON-array i formatet:', '[{"name":"genrens namn","motivation":"kort svensk mening",', '"topArtists":["artistnamn"],', '"topTracks":[{"name":"låttitel","artist":"artist/band"}]}]', 'med 5 topArtists och 5 topTracks per genre — välj genrens toppnamn,', 'men INTE artister/låtar som redan finns i listorna nedan.', '', `Artister användaren nyligen gillat/lyssnat på: ${signals.artister.join(', ') || '(ingen data)'}`, '', `Redan i biblioteket (undvik i topArtists): ${library.join(', ') || '(tomt)'}`, ].join('\n') } // ---------- Gemini + berikning ---------- async function callGemini(prompt) { const eff = effectiveConfig(config, store.load()) if (!eff.gemini.apiKey) throw new Error('Gemini-nyckel saknas — lägg in den under ⚙ Inställningar') const res = await fetchImpl( `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(eff.gemini.model)}:generateContent?key=${encodeURIComponent(eff.gemini.apiKey)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseMimeType: 'application/json', temperature: 1.0 }, }), }, ) if (!res.ok) { const t = await res.text().catch(() => '') throw new Error(`Gemini svarade ${res.status}: ${t.slice(0, 150)}`) } const js = await res.json() const text = js.candidates?.[0]?.content?.parts?.[0]?.text ?? '[]' const clean = text.replace(/^```(json)?\s*|\s*```$/g, '').trim() const arr = JSON.parse(clean) if (!Array.isArray(arr)) throw new Error('Gemini gav inte en JSON-array') return arr } async function lidarrArtistHit(name) { try { return mapLidarrSearch(await lidarr.search(name), 'artist')[0] ?? null } catch { return null } } async function enrichMix(rawItems, count) { const out = [] for (const it of rawItems.slice(0, count)) { const kind = it.type === 'album' ? 'album' : 'artist' const card = { kind, name: it.name, secondary: kind === 'album' ? (it.artist ?? '') : '', motivation: it.motivation ?? '', year: '', mbid: null, poster: null, inLibrary: false, } try { const term = kind === 'album' && it.artist ? `${it.artist} ${it.name}` : it.name const hit = mapLidarrSearch(await lidarr.search(term), kind)[0] if (hit) { card.mbid = hit.mbid card.poster = hit.poster card.inLibrary = hit.inLibrary card.year = hit.year if (kind === 'album' && hit.secondary) card.secondary = hit.secondary } } catch { /* best-effort */ } out.push(card) } return out } async function enrichTracks(rawItems, count) { const out = [] for (const it of rawItems.slice(0, count)) { const card = { kind: 'track', name: it.name, secondary: it.artist ?? '', motivation: it.motivation ?? '', year: '', mbid: null, artistMbid: null, albumMbid: null, poster: null, inLibrary: false, } const hit = await lidarrArtistHit(it.artist ?? it.name) if (hit) { card.artistMbid = hit.mbid card.mbid = hit.mbid card.poster = hit.poster } out.push(card) } return out } async function enrichGenres(rawItems, count, library, likedTitles) { const libSet = new Set(library.map((n) => n.toLowerCase())) const likedSet = new Set(likedTitles.map((t) => t.toLowerCase())) // samma artist slås ofta upp flera gånger (toppartist + låtarnas artister) const hitCache = new Map() const cachedHit = async (name) => { const key = (name ?? '').toLowerCase() if (!hitCache.has(key)) hitCache.set(key, await lidarrArtistHit(name)) return hitCache.get(key) } const out = [] for (const it of rawItems.slice(0, count)) { const topArtists = [] for (const name of (it.topArtists ?? []).slice(0, 5)) { if (libSet.has((name ?? '').toLowerCase())) continue const hit = await cachedHit(name) if (hit?.inLibrary) continue topArtists.push({ kind: 'artist', name, secondary: '', motivation: '', year: '', mbid: hit?.mbid ?? null, poster: hit?.poster ?? null, inLibrary: false, }) } const topTracks = [] for (const t of (it.topTracks ?? []).filter((t) => !likedSet.has((t.name ?? '').toLowerCase())).slice(0, 5)) { const hit = await cachedHit(t.artist ?? t.name) topTracks.push({ kind: 'track', name: t.name, secondary: t.artist ?? '', motivation: '', year: '', mbid: hit?.mbid ?? null, artistMbid: hit?.mbid ?? null, albumMbid: null, poster: hit?.poster ?? null, inLibrary: false, }) } out.push({ kind: 'genre', name: it.name, secondary: '', motivation: it.motivation ?? '', year: '', mbid: null, poster: topArtists.find((a) => a.poster)?.poster ?? null, inLibrary: false, topArtists, topTracks, }) } return out } // ---------- publikt api ---------- return { file, loadBatches, latest(limit = 10, type = 'mix') { const items = loadBatches() .filter((b) => (b.type ?? 'mix') === type) .flatMap((b) => b.items) return items.slice(0, limit) }, // alla fynd av en typ, nyast först, med batch-metadata för gruppering allItems(type = 'mix', limit = 500) { return loadBatches() .filter((b) => (b.type ?? 'mix') === type) .flatMap((b) => b.items.map((it) => ({ ...it, batchId: b.id, batchCreatedAt: b.createdAt, trigger: b.trigger })), ) .slice(0, limit) }, lastRun(type = 'mix') { return loadBatches().find((b) => (b.type ?? 'mix') === type)?.createdAt ?? null }, async run(trigger = 'manuell', type = 'mix') { const batches = loadBatches() const id = (batches[0]?.id ?? 0) + 1 const cfg = cfgFor(type) const base = { id, type, createdAt: new Date().toISOString(), trigger } if (type === 'tracks') { const signals = await collectTrackSignals(cfg.sources) const raw = await callGemini(buildTrackPrompt(signals, cfg.count)) const items = await enrichTracks(raw, cfg.count) return saveBatch(batches, { ...base, signalCounts: { gillade: signals.gillade.length, mestSpelade: signals.mestSpelade.length }, items, }) } if (type === 'genres') { const signals = await collectGenreSignals(cfg.sources) const library = await libraryArtists() const likedTitles = ytLiked(60).map((r) => r.title) const raw = await callGemini(buildGenresPrompt(signals, cfg.count, library)) const items = await enrichGenres(raw, cfg.count, library, likedTitles) return saveBatch(batches, { ...base, signalCounts: { artister: signals.artister.length, bibliotek: library.length }, items, }) } const signals = await collectMixSignals(cfg.sources) const raw = await callGemini(buildMixPrompt(signals, cfg.count)) const items = await enrichMix(raw, cfg.count) return saveBatch(batches, { ...base, type: 'mix', signalCounts: { bibliotek: signals.bibliotek.length, spelat: signals.nyligenSpelat.length, tillagt: signals.nyligenTillagt.length, }, items, }) }, } }