All checks were successful
build-and-push / build (push) Successful in 12s
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
418 lines
14 KiB
JavaScript
418 lines
14 KiB
JavaScript
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,
|
||
})
|
||
},
|
||
}
|
||
}
|