diff --git a/backend/src/app.js b/backend/src/app.js index abe31ff..919b4a9 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -168,6 +168,7 @@ export function buildApp(config, deps = {}) { }, suggest: { intervalHours: s.suggest?.intervalHours ?? 24, + trackIntervalDays: s.suggest?.trackIntervalDays ?? 7, }, } }) @@ -192,14 +193,19 @@ export function buildApp(config, deps = {}) { body.suggest?.intervalHours === undefined || body.suggest?.intervalHours === null ? undefined : Math.max(0, Number(body.suggest.intervalHours) || 0), + trackIntervalDays: + body.suggest?.trackIntervalDays === undefined || body.suggest?.trackIntervalDays === null + ? undefined + : Math.max(0, Number(body.suggest.trackIntervalDays) || 0), }, }) return { ok: true } }) app.post('/api/suggest/run', async (req, reply) => { + const type = req.body?.type === 'tracks' ? 'tracks' : 'mix' try { - const batch = await suggest.run('manuell') + const batch = await suggest.run('manuell', type) return { ok: true, batchId: batch.id, count: batch.items.length } } catch (err) { req.log.error({ err }, 'förslagsgenerering misslyckades') @@ -210,15 +216,19 @@ export function buildApp(config, deps = {}) { app.get('/api/suggest/latest', async () => { const s = store.load() return { - items: suggest.latest(10), - lastRun: suggest.lastRun(), + items: suggest.latest(10, 'mix'), + tracks: suggest.latest(10, 'tracks'), + lastRun: suggest.lastRun('mix'), + lastTrackRun: suggest.lastRun('tracks'), intervalHours: s.suggest?.intervalHours ?? 24, + trackIntervalDays: s.suggest?.trackIntervalDays ?? 7, } }) app.get('/api/suggest/batches', async () => { return suggest.loadBatches().map((b) => ({ id: b.id, + type: b.type ?? 'mix', createdAt: b.createdAt, trigger: b.trigger, count: b.items.length, diff --git a/backend/src/server.js b/backend/src/server.js index 9a5d3e9..7edecaf 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -15,20 +15,30 @@ app.listen({ port: config.port, host: config.host }).catch((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). +// Schemaläggaren: var 10:e minut, kolla om det är dags för nya +// förslags-batcher. Artist/album-mixen styrs av intervalHours (0 = av), +// låtförslagen av trackIntervalDays (default veckovis, 0 = av). const CHECK_MS = 10 * 60 * 1000 + +async function runIfDue(type, ms) { + if (!ms) return + const last = app.suggest.lastRun(type) + if (last && Date.now() - Date.parse(last) < ms) return + app.log.info(`schemalagd förslagsgenerering (${type}) startar`) + const batch = await app.suggest.run('schemalagd', type) + app.log.info(`schemalagd batch #${batch.id} (${type}) klar med ${batch.items.length} förslag`) +} + setInterval(async () => { + const s = app.settingsStore.load().suggest ?? {} 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`) + await runIfDue('mix', (s.intervalHours ?? 24) * 3600 * 1000) } catch (err) { - app.log.warn({ err }, 'schemalagd förslagsgenerering misslyckades') + app.log.warn({ err }, 'schemalagd mix-generering misslyckades') + } + try { + await runIfDue('tracks', (s.trackIntervalDays ?? 7) * 24 * 3600 * 1000) + } catch (err) { + app.log.warn({ err }, 'schemalagd låt-generering misslyckades') } }, CHECK_MS) diff --git a/backend/src/suggest.js b/backend/src/suggest.js index 5c2b2f8..f906f71 100644 --- a/backend/src/suggest.js +++ b/backend/src/suggest.js @@ -77,6 +77,76 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { return signals } + async function collectTrackSignals() { + const eff = effectiveConfig(config, store.load()) + const signals = { gillade: [], mestSpelade: [] } + + try { + if (existsSync(config.youtubarr.dbPath)) { + const db = new Database(config.youtubarr.dbPath, { readonly: true, fileMustExist: true }) + // Gillade = Liked Music-listan (LM) om den finns, annars senaste tilläggen + let rows = db + .prepare( + `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 10`, + ) + .all() + if (rows.length === 0) { + rows = db + .prepare( + `SELECT ti.title, ti.artist_name_guess + FROM youtubarr_trackitem ti + WHERE ti.blacklisted = 0 + ORDER BY ti.id DESC LIMIT 10`, + ) + .all() + } + db.close() + signals.gillade = rows.map((r) => `${r.artist_name_guess || '?'} – ${r.title}`) + } + } catch { + /* kör vidare utan */ + } + + if (eff.jellyfin.apiKey && eff.jellyfin.userId) { + try { + const url = + `${eff.jellyfin.url}/Users/${eff.jellyfin.userId}/Items` + + `?IncludeItemTypes=Audio&SortBy=PlayCount&SortOrder=Descending` + + `&Filters=IsPlayed&Recursive=true&Limit=10` + const res = await fetchImpl(url, { headers: { 'X-Emby-Token': eff.jellyfin.apiKey } }) + if (res.ok) { + const js = await res.json() + signals.mestSpelade = (js.Items ?? []).map( + (i) => `${(i.Artists ?? [])[0] ?? i.AlbumArtist ?? '?'} – ${i.Name}`, + ) + } + } catch { + /* kör vidare utan */ + } + } + + return signals + } + + function buildTrackPrompt(signals) { + return [ + 'Du är en kunnig musikrekommendatör. Utifrån låtarna nedan, föreslå', + 'exakt 10 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 buildPrompt(signals) { return [ 'Du är en kunnig musikrekommendatör. Utifrån signalerna nedan, föreslå', @@ -95,7 +165,7 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { ].join('\n') } - async function callGemini(signals) { + 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( @@ -104,7 +174,7 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - contents: [{ parts: [{ text: buildPrompt(signals) }] }], + contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseMimeType: 'application/json', temperature: 1.0 }, }), }, @@ -153,26 +223,79 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { return out } + async function enrichTracks(rawItems) { + const out = [] + for (const it of rawItems.slice(0, 12)) { + const card = { + kind: 'track', + name: it.name, + secondary: it.artist ?? '', + motivation: it.motivation ?? '', + year: '', + mbid: null, + artistMbid: null, + albumMbid: null, + poster: null, + inLibrary: false, + } + try { + const hit = mapLidarrSearch(await lidarr.search(it.artist ?? it.name), 'artist')[0] + if (hit) { + card.artistMbid = hit.mbid + card.mbid = hit.mbid + card.poster = hit.poster + } + } catch { + /* best-effort */ + } + out.push(card) + } + return out + } + + function saveBatch(batches, batch) { + batches.unshift(batch) + saveBatches(batches) + return batch + } + return { file, loadBatches, - latest(limit = 10) { - const items = loadBatches().flatMap((b) => b.items) + latest(limit = 10, type = 'mix') { + const items = loadBatches() + .filter((b) => (b.type ?? 'mix') === type) + .flatMap((b) => b.items) return items.slice(0, limit) }, - lastRun() { - return loadBatches()[0]?.createdAt ?? null + lastRun(type = 'mix') { + return loadBatches().find((b) => (b.type ?? 'mix') === type)?.createdAt ?? null }, - async run(trigger = 'manuell') { - const signals = await collectSignals() - const raw = await callGemini(signals) - const items = await enrich(raw) + async run(trigger = 'manuell', type = 'mix') { const batches = loadBatches() - const batch = { - id: (batches[0]?.id ?? 0) + 1, + const id = (batches[0]?.id ?? 0) + 1 + if (type === 'tracks') { + const signals = await collectTrackSignals() + const raw = await callGemini(buildTrackPrompt(signals)) + const items = await enrichTracks(raw) + return saveBatch(batches, { + id, + type: 'tracks', + createdAt: new Date().toISOString(), + trigger, + signalCounts: { gillade: signals.gillade.length, mestSpelade: signals.mestSpelade.length }, + items, + }) + } + const signals = await collectSignals() + const raw = await callGemini(buildPrompt(signals)) + const items = await enrich(raw) + return saveBatch(batches, { + id, + type: 'mix', createdAt: new Date().toISOString(), trigger, signalCounts: { @@ -181,10 +304,7 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { 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 index 9bed54b..3326f00 100644 --- a/backend/test/suggest.test.js +++ b/backend/test/suggest.test.js @@ -11,14 +11,16 @@ 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_playlist (id INTEGER PRIMARY KEY, playlist_id TEXT, 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_playlist VALUES (1, 'PLC0x', 'lefu-Musick'); + INSERT INTO youtubarr_playlist VALUES (2, 'LM', 'Liked Music'); INSERT INTO youtubarr_trackitem VALUES (1, 1, 'Amaranth', 'Nightwish', 0); INSERT INTO youtubarr_trackitem VALUES (2, 1, 'Svart', 'Hemligt Band', 1); + INSERT INTO youtubarr_trackitem VALUES (3, 2, 'Gimme! Gimme! Gimme!', 'ABBA', 0); `) db.close() return p @@ -109,6 +111,29 @@ test('run: markdown-staket runt JSON hanteras', async () => { assert.equal(batch.items[0].name, 'Ghost') }) +test('run(tracks): gillade + mest spelade in i prompten, egen batch-typ', async () => { + const { engine, fetchImpl } = setup({ + geminiResponse: JSON.stringify([ + { name: 'Dancing Queen', artist: 'ABBA', motivation: 'Klassiker.' }, + ]), + }) + const batch = await engine.run('manuell', 'tracks') + + assert.match(fetchImpl.prompt, /ENSKILDA LÅTAR/) + assert.match(fetchImpl.prompt, /ABBA – Gimme! Gimme! Gimme!/, 'Liked Music-låtar ska in') + assert.match(fetchImpl.prompt, /1000mods – Zenit/, 'mest spelade ska in') + + assert.equal(batch.type, 'tracks') + assert.equal(batch.items[0].kind, 'track') + assert.equal(batch.items[0].secondary, 'ABBA') + assert.equal(batch.items[0].artistMbid, 'mbid-x', 'artist-mbid berikas för Hämta') + + assert.equal(engine.latest(10, 'tracks').length, 1) + assert.equal(engine.latest(10, 'mix').length, 0, 'låt-batcher ska inte blandas in i mixen') + assert.equal(engine.lastRun('tracks'), batch.createdAt) + assert.equal(engine.lastRun('mix'), null) +}) + test('run utan Gemini-nyckel ger begripligt fel', async () => { const { engine, dataDir } = setup() const store = settingsStore(dataDir) diff --git a/frontend/src/components/MusicCard.vue b/frontend/src/components/MusicCard.vue index a083858..e5e5118 100644 --- a/frontend/src/components/MusicCard.vue +++ b/frontend/src/components/MusicCard.vue @@ -1,9 +1,17 @@