Files
lyssnarr/backend/src/suggest.js
claude 4e89bd1986
All checks were successful
build-and-push / build (push) Successful in 12s
Förslag-sidan: typ-knappar i vänsterspalten, alla fynd nyast först
Tre knappar (Artister & album / Låtar / Genrer) ersätter batch-listan;
huvudspalten visar alla cachade fynd av vald typ i tidsordning med
nyast först, grupperade per körning med datum-avdelare. Nytt API
GET /api/suggest/items?type=X (plattar batcher med batch-metadata).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
2026-08-01 03:07:54 +02:00

418 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
})
},
}
}