diff --git a/backend/package-lock.json b/backend/package-lock.json index d74d21b..35901e3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "@fastify/cookie": "^11.0.2", "@fastify/session": "^11.1.0", "@fastify/static": "^8.1.1", + "better-sqlite3": "^13.0.2", "fastify": "^5.3.2" } }, @@ -345,6 +346,18 @@ "node": "18 || 20 || >=22" } }, + "node_modules/better-sqlite3": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -785,6 +798,15 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/node-addon-api": { + "version": "8.9.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", + "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index b23fb8e..0029f23 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "@fastify/cookie": "^11.0.2", "@fastify/session": "^11.1.0", "@fastify/static": "^8.1.1", + "better-sqlite3": "^13.0.2", "fastify": "^5.3.2" } } diff --git a/backend/src/app.js b/backend/src/app.js index 7a702b3..abe31ff 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -12,6 +12,7 @@ import { mapLidarrSearch } from './mapping.js' import { settingsStore } from './settings.js' import { makeTesters } from './testers.js' import { ytmusicFlow } from './ytmusic.js' +import { suggestEngine } from './suggest.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -30,6 +31,7 @@ export function buildApp(config, deps = {}) { const store = deps.store ?? settingsStore(config.dataDir ?? '/data') const testers = deps.testers ?? makeTesters({ config, store }) const ytmusic = deps.ytmusic ?? ytmusicFlow({ config, store }) + const suggest = deps.suggest ?? suggestEngine({ config, store, lidarr }) const app = Fastify({ logger: true, trustProxy: true }) app.register(fastifyCookie) @@ -164,6 +166,9 @@ export function buildApp(config, deps = {}) { clientSecretSet: Boolean(s.ytmusic?.clientSecret), connected: existsSync(ytmusic.oauthPath), }, + suggest: { + intervalHours: s.suggest?.intervalHours ?? 24, + }, } }) @@ -182,10 +187,51 @@ export function buildApp(config, deps = {}) { clientId: body.ytmusic?.clientId, clientSecret: body.ytmusic?.clientSecret, }, + suggest: { + intervalHours: + body.suggest?.intervalHours === undefined || body.suggest?.intervalHours === null + ? undefined + : Math.max(0, Number(body.suggest.intervalHours) || 0), + }, }) return { ok: true } }) + app.post('/api/suggest/run', async (req, reply) => { + try { + const batch = await suggest.run('manuell') + return { ok: true, batchId: batch.id, count: batch.items.length } + } catch (err) { + req.log.error({ err }, 'förslagsgenerering misslyckades') + return reply.code(502).send({ error: err.message }) + } + }) + + app.get('/api/suggest/latest', async () => { + const s = store.load() + return { + items: suggest.latest(10), + lastRun: suggest.lastRun(), + intervalHours: s.suggest?.intervalHours ?? 24, + } + }) + + app.get('/api/suggest/batches', async () => { + return suggest.loadBatches().map((b) => ({ + id: b.id, + createdAt: b.createdAt, + trigger: b.trigger, + count: b.items.length, + signalCounts: b.signalCounts, + })) + }) + + app.get('/api/suggest/batches/:id', async (req, reply) => { + const batch = suggest.loadBatches().find((b) => b.id === Number(req.params.id)) + if (!batch) return reply.code(404).send({ error: 'batchen finns inte' }) + return batch + }) + app.post('/api/ytmusic/start', async (req, reply) => { try { const out = await ytmusic.start() @@ -226,5 +272,8 @@ export function buildApp(config, deps = {}) { }) } + app.decorate('suggest', suggest) + app.decorate('settingsStore', store) + return app } diff --git a/backend/src/lidarr.js b/backend/src/lidarr.js index 0edefce..1334b83 100644 --- a/backend/src/lidarr.js +++ b/backend/src/lidarr.js @@ -26,6 +26,7 @@ export function lidarrClient(config) { return { search: (term) => req('GET', `/api/v1/search?term=${encodeURIComponent(term)}`), + artists: () => req('GET', '/api/v1/artist'), addArtist: (payload) => req('POST', '/api/v1/artist', payload), addAlbum: (payload) => req('POST', '/api/v1/album', payload), } diff --git a/backend/src/server.js b/backend/src/server.js index 8bd8f75..9a5d3e9 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -14,3 +14,21 @@ app.listen({ port: config.port, host: config.host }).catch((err) => { app.log.error(err) process.exit(1) }) + +// Schemaläggaren: var 10:e minut, kolla om det är dags för en ny +// förslags-batch (intervall i timmar från inställningarna; 0 = av). +const CHECK_MS = 10 * 60 * 1000 +setInterval(async () => { + try { + const hours = app.settingsStore.load().suggest?.intervalHours ?? 24 + if (!hours) return + const last = app.suggest.lastRun() + const due = !last || Date.now() - Date.parse(last) >= hours * 3600 * 1000 + if (!due) return + app.log.info('schemalagd förslagsgenerering startar') + const batch = await app.suggest.run('schemalagd') + app.log.info(`schemalagd batch #${batch.id} klar med ${batch.items.length} förslag`) + } catch (err) { + app.log.warn({ err }, 'schemalagd förslagsgenerering misslyckades') + } +}, CHECK_MS) diff --git a/backend/src/suggest.js b/backend/src/suggest.js new file mode 100644 index 0000000..5c2b2f8 --- /dev/null +++ b/backend/src/suggest.js @@ -0,0 +1,190 @@ +import Database from 'better-sqlite3' +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import path from 'node:path' +import { effectiveConfig } from './settings.js' +import { mapLidarrSearch } from './mapping.js' + +// Förslagsmotorn: samlar smak-signaler (Lidarr-bibliotek, Jellyfin-historik, +// YT Music-tillägg via Youtubarrs databas), ber Gemini om nya förslag, +// berikar med MBID/omslag via Lidarrs metadata-API och cachar batcher +// på disk (/data/suggestions.json). +export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { + const file = path.join(config.dataDir ?? '/data', 'suggestions.json') + + function loadBatches() { + try { + return JSON.parse(readFileSync(file, 'utf8')) + } catch { + return [] + } + } + + function saveBatches(batches) { + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify(batches, null, 2)) + } + + async function collectSignals() { + const eff = effectiveConfig(config, store.load()) + const signals = { bibliotek: [], nyligenSpelat: [], nyligenTillagt: [] } + + try { + const artists = await lidarr.artists() + signals.bibliotek = artists.map((a) => a.artistName).slice(0, 150) + } catch { + /* biblioteket är tomt eller Lidarr nere — kör vidare */ + } + + if (eff.jellyfin.apiKey && eff.jellyfin.userId) { + try { + const url = + `${eff.jellyfin.url}/Users/${eff.jellyfin.userId}/Items` + + `?IncludeItemTypes=Audio&SortBy=DatePlayed&SortOrder=Descending` + + `&Filters=IsPlayed&Recursive=true&Limit=30` + const res = await fetchImpl(url, { headers: { 'X-Emby-Token': eff.jellyfin.apiKey } }) + if (res.ok) { + const js = await res.json() + signals.nyligenSpelat = (js.Items ?? []).map( + (i) => `${(i.Artists ?? [])[0] ?? i.AlbumArtist ?? '?'} – ${i.Name}`, + ) + } + } catch { + /* Jellyfin nere — kör vidare */ + } + } + + try { + if (existsSync(config.youtubarr.dbPath)) { + const db = new Database(config.youtubarr.dbPath, { readonly: true, fileMustExist: true }) + const rows = db + .prepare( + `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`, + ) + .all() + db.close() + signals.nyligenTillagt = rows.map( + (r) => `${r.artist_name_guess || '?'} – ${r.title}${r.playlist ? ` [${r.playlist}]` : ''}`, + ) + } + } catch { + /* databasen saknas/låst — kör vidare */ + } + + return signals + } + + function buildPrompt(signals) { + return [ + 'Du är en kunnig musikrekommendatör. Utifrån signalerna nedan, föreslå', + 'exakt 10 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') + } + + async function callGemini(signals) { + 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: buildPrompt(signals) }] }], + 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 enrich(rawItems) { + const out = [] + for (const it of rawItems.slice(0, 12)) { + 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 { + /* berikning är best-effort */ + } + out.push(card) + } + return out + } + + return { + file, + loadBatches, + + latest(limit = 10) { + const items = loadBatches().flatMap((b) => b.items) + return items.slice(0, limit) + }, + + lastRun() { + return loadBatches()[0]?.createdAt ?? null + }, + + async run(trigger = 'manuell') { + const signals = await collectSignals() + const raw = await callGemini(signals) + const items = await enrich(raw) + const batches = loadBatches() + const batch = { + id: (batches[0]?.id ?? 0) + 1, + createdAt: new Date().toISOString(), + trigger, + signalCounts: { + bibliotek: signals.bibliotek.length, + spelat: signals.nyligenSpelat.length, + tillagt: signals.nyligenTillagt.length, + }, + items, + } + batches.unshift(batch) + saveBatches(batches) + return batch + }, + } +} diff --git a/backend/test/suggest.test.js b/backend/test/suggest.test.js new file mode 100644 index 0000000..9bed54b --- /dev/null +++ b/backend/test/suggest.test.js @@ -0,0 +1,117 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import Database from 'better-sqlite3' +import { settingsStore } from '../src/settings.js' +import { suggestEngine } from '../src/suggest.js' + +function makeYoutubarrDb(dir) { + const p = path.join(dir, 'db.sqlite3') + const db = new Database(p) + db.exec(` + CREATE TABLE youtubarr_playlist (id INTEGER PRIMARY KEY, title TEXT); + CREATE TABLE youtubarr_trackitem ( + id INTEGER PRIMARY KEY, playlist_id INTEGER, title TEXT, + artist_name_guess TEXT, blacklisted INTEGER DEFAULT 0 + ); + INSERT INTO youtubarr_playlist VALUES (1, 'lefu-Musick'); + INSERT INTO youtubarr_trackitem VALUES (1, 1, 'Amaranth', 'Nightwish', 0); + INSERT INTO youtubarr_trackitem VALUES (2, 1, 'Svart', 'Hemligt Band', 1); + `) + db.close() + return p +} + +function setup({ geminiResponse } = {}) { + const dataDir = mkdtempSync(path.join(tmpdir(), 'lyssnarr-sug-')) + const ytDir = mkdtempSync(path.join(tmpdir(), 'lyssnarr-ytdb-')) + const store = settingsStore(dataDir) + store.save({ gemini: { apiKey: 'g-nyckel' }, jellyfin: { apiKey: 'jf', userId: 'u1' } }) + const config = { + dataDir, + gemini: { apiKey: '', model: 'gemini-2.5-flash' }, + jellyfin: { url: 'http://jellyfin:8096', apiKey: '' }, + youtubarr: { dbPath: makeYoutubarrDb(ytDir), dataDir: ytDir }, + } + const lidarr = { + artists: async () => [{ artistName: 'Powerwolf' }], + search: async () => [ + { + artist: { + foreignArtistId: 'mbid-x', + artistName: 'Sabaton', + images: [{ coverType: 'poster', remoteUrl: 'http://img/s.jpg' }], + }, + }, + ], + } + const calls = [] + const fetchImpl = async (url, opts) => { + calls.push(url) + if (url.includes('generativelanguage')) { + const body = JSON.parse(opts.body) + fetchImpl.prompt = body.contents[0].parts[0].text + return { + ok: true, + json: async () => ({ + candidates: [{ content: { parts: [{ text: geminiResponse ?? JSON.stringify([ + { type: 'artist', name: 'Sabaton', motivation: 'Powermetal som Powerwolf.' }, + ]) }] } }], + }), + } + } + if (url.includes('/Users/u1/Items')) { + return { ok: true, json: async () => ({ Items: [{ Name: 'Zenit', Artists: ['1000mods'] }] }) } + } + throw new Error('oväntad url: ' + url) + } + const engine = suggestEngine({ config, store, lidarr, fetchImpl }) + return { engine, fetchImpl, dataDir } +} + +test('run: samlar signaler, frågar Gemini, berikar och cachar på disk', async () => { + const { engine, fetchImpl, dataDir } = setup() + const batch = await engine.run('manuell') + + assert.match(fetchImpl.prompt, /Powerwolf/, 'biblioteket ska in i prompten') + assert.match(fetchImpl.prompt, /1000mods – Zenit/, 'jellyfin-historiken ska in i prompten') + assert.match(fetchImpl.prompt, /Nightwish – Amaranth \[lefu-Musick\]/, 'YT-tillägg ska in i prompten') + assert.ok(!fetchImpl.prompt.includes('Hemligt Band'), 'svartlistade låtar ska inte med') + + assert.equal(batch.items.length, 1) + const item = batch.items[0] + assert.equal(item.name, 'Sabaton') + assert.equal(item.mbid, 'mbid-x') + assert.equal(item.poster, 'http://img/s.jpg') + assert.equal(item.motivation, 'Powermetal som Powerwolf.') + + assert.ok(existsSync(path.join(dataDir, 'suggestions.json'))) + assert.equal(engine.latest(10).length, 1) + assert.equal(engine.lastRun(), batch.createdAt) +}) + +test('run: batch-id räknas upp och latest tar nyaste först', async () => { + const { engine } = setup() + const b1 = await engine.run() + const b2 = await engine.run() + assert.equal(b2.id, b1.id + 1) + assert.equal(engine.loadBatches().length, 2) + assert.equal(engine.loadBatches()[0].id, b2.id) +}) + +test('run: markdown-staket runt JSON hanteras', async () => { + const { engine } = setup({ + geminiResponse: '```json\n[{"type":"artist","name":"Ghost","motivation":"x"}]\n```', + }) + const batch = await engine.run() + assert.equal(batch.items[0].name, 'Ghost') +}) + +test('run utan Gemini-nyckel ger begripligt fel', async () => { + const { engine, dataDir } = setup() + const store = settingsStore(dataDir) + store.save({ gemini: { apiKey: '' } }) + await assert.rejects(() => engine.run(), /Gemini-nyckel saknas/) +}) diff --git a/docker/Dockerfile b/docker/Dockerfile index b34564d..e90c28c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -9,6 +9,9 @@ COPY frontend/ ./ RUN npm run build FROM node:22-slim AS backend +# python3/make/g++ behövs om better-sqlite3 saknar prebuilt för plattformen +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY backend/package.json backend/package-lock.json ./ RUN npm ci --omit=dev --no-audit --no-fund diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ae5f69b..315e151 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -33,6 +33,7 @@ async function logout() { + Förslag @@ -101,6 +102,16 @@ async function logout() { text-decoration: none; } +.navlink { + color: var(--text-dim); + white-space: nowrap; +} + +.navlink:hover, +.navlink.router-link-active { + color: var(--text); +} + .ghost:hover { background: rgba(255, 255, 255, 0.06); color: var(--text); diff --git a/frontend/src/components/MusicCard.vue b/frontend/src/components/MusicCard.vue new file mode 100644 index 0000000..a083858 --- /dev/null +++ b/frontend/src/components/MusicCard.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/frontend/src/pages/DiscoverPage.vue b/frontend/src/pages/DiscoverPage.vue index 02571d8..7ac4037 100644 --- a/frontend/src/pages/DiscoverPage.vue +++ b/frontend/src/pages/DiscoverPage.vue @@ -1,25 +1,120 @@ diff --git a/frontend/src/pages/SearchPage.vue b/frontend/src/pages/SearchPage.vue index efb3c45..668fbeb 100644 --- a/frontend/src/pages/SearchPage.vue +++ b/frontend/src/pages/SearchPage.vue @@ -1,12 +1,12 @@ + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index de06e45..44b7926 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -3,12 +3,14 @@ import DiscoverPage from './pages/DiscoverPage.vue' import LoginPage from './pages/LoginPage.vue' import SearchPage from './pages/SearchPage.vue' import SettingsPage from './pages/SettingsPage.vue' +import SuggestionsPage from './pages/SuggestionsPage.vue' export const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'discover', component: DiscoverPage }, { path: '/search', name: 'search', component: SearchPage }, + { path: '/forslag', name: 'suggestions', component: SuggestionsPage }, { path: '/settings', name: 'settings', component: SettingsPage }, { path: '/login', name: 'login', component: LoginPage }, ],