diff --git a/backend/src/app.js b/backend/src/app.js index 919b4a9..5fa5b78 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -9,7 +9,7 @@ import fastifyStatic from '@fastify/static' import { lidarrClient, LidarrError } from './lidarr.js' import { mbClient } from './musicbrainz.js' import { mapLidarrSearch } from './mapping.js' -import { settingsStore } from './settings.js' +import { settingsStore, suggestConfig } from './settings.js' import { makeTesters } from './testers.js' import { ytmusicFlow } from './ytmusic.js' import { suggestEngine } from './suggest.js' @@ -18,6 +18,27 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) const OPEN_ROUTES = new Set(['/api/health', '/api/login', '/api/session']) +const SUGGEST_TYPES = ['mix', 'tracks', 'singers'] +const SUGGEST_UNITS = new Set(['hours', 'days', 'months']) + +function sanitizeSuggest(body) { + if (!body) return {} + const out = {} + for (const type of SUGGEST_TYPES) { + const t = body[type] + if (!t) continue + out[type] = { + every: Math.max(0, Number(t.every) || 0), + unit: SUGGEST_UNITS.has(t.unit) ? t.unit : 'days', + count: Math.min(25, Math.max(1, Number(t.count) || 10)), + sources: Object.fromEntries( + Object.entries(t.sources ?? {}).map(([k, v]) => [k, Boolean(v)]), + ), + } + } + return out +} + function passwordOk(given, expected) { if (typeof given !== 'string' || !expected) return false const a = Buffer.from(given) @@ -166,10 +187,7 @@ export function buildApp(config, deps = {}) { clientSecretSet: Boolean(s.ytmusic?.clientSecret), connected: existsSync(ytmusic.oauthPath), }, - suggest: { - intervalHours: s.suggest?.intervalHours ?? 24, - trackIntervalDays: s.suggest?.trackIntervalDays ?? 7, - }, + suggest: suggestConfig(s), } }) @@ -188,22 +206,13 @@ 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), - trackIntervalDays: - body.suggest?.trackIntervalDays === undefined || body.suggest?.trackIntervalDays === null - ? undefined - : Math.max(0, Number(body.suggest.trackIntervalDays) || 0), - }, + suggest: sanitizeSuggest(body.suggest), }) return { ok: true } }) app.post('/api/suggest/run', async (req, reply) => { - const type = req.body?.type === 'tracks' ? 'tracks' : 'mix' + const type = SUGGEST_TYPES.includes(req.body?.type) ? req.body.type : 'mix' try { const batch = await suggest.run('manuell', type) return { ok: true, batchId: batch.id, count: batch.items.length } @@ -214,14 +223,15 @@ export function buildApp(config, deps = {}) { }) app.get('/api/suggest/latest', async () => { - const s = store.load() + const cfg = suggestConfig(store.load()) return { - items: suggest.latest(10, 'mix'), - tracks: suggest.latest(10, 'tracks'), + items: suggest.latest(cfg.mix.count, 'mix'), + tracks: suggest.latest(cfg.tracks.count, 'tracks'), + singers: suggest.latest(cfg.singers.count, 'singers'), lastRun: suggest.lastRun('mix'), lastTrackRun: suggest.lastRun('tracks'), - intervalHours: s.suggest?.intervalHours ?? 24, - trackIntervalDays: s.suggest?.trackIntervalDays ?? 7, + lastSingersRun: suggest.lastRun('singers'), + schedule: cfg, } }) diff --git a/backend/src/server.js b/backend/src/server.js index 7edecaf..e7312a4 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,5 +1,6 @@ import { loadConfig } from './config.js' import { buildApp } from './app.js' +import { suggestConfig, UNIT_MS } from './settings.js' const config = loadConfig() @@ -30,15 +31,12 @@ async function runIfDue(type, ms) { } setInterval(async () => { - const s = app.settingsStore.load().suggest ?? {} - try { - await runIfDue('mix', (s.intervalHours ?? 24) * 3600 * 1000) - } catch (err) { - 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') + const cfg = suggestConfig(app.settingsStore.load()) + for (const type of ['mix', 'tracks', 'singers']) { + try { + await runIfDue(type, cfg[type].every * UNIT_MS[cfg[type].unit]) + } catch (err) { + app.log.warn({ err }, `schemalagd ${type}-generering misslyckades`) + } } }, CHECK_MS) diff --git a/backend/src/settings.js b/backend/src/settings.js index aeda164..46c77f4 100644 --- a/backend/src/settings.js +++ b/backend/src/settings.js @@ -34,6 +34,46 @@ export function settingsStore(dataDir) { return { load, save, file } } +// Förslags-scheman: tre typer med intervall (värde + enhet), antal och +// källo-toggles. Gamla nycklarna intervalHours/trackIntervalDays migreras. +const SUGGEST_DEFAULTS = { + mix: { every: 24, unit: 'hours', count: 10, sources: { bibliotek: true, spelat: true, tillagt: true } }, + tracks: { every: 7, unit: 'days', count: 10, sources: { gillade: true, mestSpelade: true } }, + singers: { every: 1, unit: 'months', count: 8, sources: { gillade: true, spelat: true } }, +} + +export const UNIT_MS = { + hours: 3600 * 1000, + days: 24 * 3600 * 1000, + months: 30 * 24 * 3600 * 1000, +} + +export function suggestConfig(settings) { + const s = settings.suggest ?? {} + const out = {} + for (const [type, def] of Object.entries(SUGGEST_DEFAULTS)) { + const t = s[type] ?? {} + let every = t.every + let unit = UNIT_MS[t.unit] ? t.unit : undefined + if (every === undefined) { + if (type === 'mix' && s.intervalHours !== undefined) { + every = Number(s.intervalHours) + unit ??= 'hours' + } else if (type === 'tracks' && s.trackIntervalDays !== undefined) { + every = Number(s.trackIntervalDays) + unit ??= 'days' + } + } + out[type] = { + every: Math.max(0, Number(every ?? def.every) || 0), + unit: unit ?? def.unit, + count: Math.min(25, Math.max(1, Number(t.count ?? def.count) || def.count)), + sources: { ...def.sources, ...(t.sources ?? {}) }, + } + } + return out +} + // Effektiv konfig = sparade inställningar med env som fallback. export function effectiveConfig(config, settings) { return { diff --git a/backend/src/suggest.js b/backend/src/suggest.js index f906f71..df41339 100644 --- a/backend/src/suggest.js +++ b/backend/src/suggest.js @@ -1,16 +1,21 @@ import Database from 'better-sqlite3' import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' import path from 'node:path' -import { effectiveConfig } from './settings.js' +import { effectiveConfig, suggestConfig } 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). +// 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')) @@ -19,138 +24,119 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { } } - function saveBatches(batches) { + function saveBatch(batches, batch) { + batches.unshift(batch) mkdirSync(path.dirname(file), { recursive: true }) writeFileSync(file, JSON.stringify(batches, null, 2)) + return batch } - async function collectSignals() { + // ---------- 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: [] } - - 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 (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 (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 */ - } + 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}]` : ''}`) } - - 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 } - async function collectTrackSignals() { - const eff = effectiveConfig(config, store.load()) + async function collectTrackSignals(sources) { 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 (sources.gillade) { + signals.gillade = ytLiked(10).map((r) => `${r.artist_name_guess || '?'} – ${r.title}`) } - - 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 */ - } + if (sources.mestSpelade) { + signals.mestSpelade = (await jellyfinItems('PlayCount', 10)).map((i) => `${i.artist} – ${i.name}`) } - 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') + async function collectSingerSignals(sources) { + const seen = new Set() + const singers = [] + const add = (name) => { + const key = (name ?? '').trim().toLowerCase() + if (!key || key === '?' || seen.has(key)) return + seen.add(key) + singers.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 { sangare: singers.slice(0, 10) } } - function buildPrompt(signals) { + // ---------- prompter ---------- + + function buildMixPrompt(signals, count) { 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', + `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":"..."}]', @@ -165,6 +151,41 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { ].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 buildSingersPrompt(signals, count, library) { + return [ + 'Du är en kunnig musikrekommendatör med fokus på SÅNGARE/VOKALISTER.', + `Utifrån sångarna nedan, föreslå exakt ${count} ANDRA sångare/vokalister`, + 'som användaren troligen skulle uppskatta (liknande röst, genre eller', + 'känsla). Föreslå INTE någon som redan finns i listorna nedan.', + 'Svara ENBART med en JSON-array i formatet:', + '[{"name":"sångarens namn","motivation":"kort svensk mening",', + '"topTracks":[{"name":"låttitel","artist":"artist/band"}],', + '"relatedArtists":["artistnamn"]}]', + 'med 5 topTracks och 3 relatedArtists per sångare.', + '', + `De senaste unika sångarna användaren gillat/lyssnat på: ${signals.sangare.join(', ') || '(ingen data)'}`, + '', + `Redan i biblioteket (undvik): ${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') @@ -191,9 +212,17 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { return arr } - async function enrich(rawItems) { + 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, 12)) { + for (const it of rawItems.slice(0, count)) { const kind = it.type === 'album' ? 'album' : 'artist' const card = { kind, @@ -216,16 +245,16 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { if (kind === 'album' && hit.secondary) card.secondary = hit.secondary } } catch { - /* berikning är best-effort */ + /* best-effort */ } out.push(card) } return out } - async function enrichTracks(rawItems) { + async function enrichTracks(rawItems, count) { const out = [] - for (const it of rawItems.slice(0, 12)) { + for (const it of rawItems.slice(0, count)) { const card = { kind: 'track', name: it.name, @@ -238,27 +267,53 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { 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 */ + 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 } - function saveBatch(batches, batch) { - batches.unshift(batch) - saveBatches(batches) - return batch + async function enrichSingers(rawItems, count, library, likedTitles) { + const libSet = new Set(library.map((n) => n.toLowerCase())) + const likedSet = new Set(likedTitles.map((t) => t.toLowerCase())) + const out = [] + for (const it of rawItems.slice(0, count + 3)) { + if (out.length >= count) break + const hit = await lidarrArtistHit(it.name) + if (hit?.inLibrary || libSet.has((it.name ?? '').toLowerCase())) continue + const related = [] + for (const name of (it.relatedArtists ?? []).slice(0, 3)) { + if (libSet.has((name ?? '').toLowerCase())) continue + const rHit = await lidarrArtistHit(name) + if (rHit?.inLibrary) continue + related.push({ name, mbid: rHit?.mbid ?? null }) + } + out.push({ + kind: 'singer', + name: it.name, + secondary: '', + motivation: it.motivation ?? '', + year: '', + mbid: hit?.mbid ?? null, + poster: hit?.poster ?? null, + inLibrary: false, + topTracks: (it.topTracks ?? []) + .filter((t) => !likedSet.has((t.name ?? '').toLowerCase())) + .slice(0, 5) + .map((t) => ({ name: t.name, artist: t.artist ?? it.name })), + relatedArtists: related, + }) + } + return out } + // ---------- publikt api ---------- + return { file, loadBatches, @@ -277,27 +332,39 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { 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() - const raw = await callGemini(buildTrackPrompt(signals)) - const items = await enrichTracks(raw) + const signals = await collectTrackSignals(cfg.sources) + const raw = await callGemini(buildTrackPrompt(signals, cfg.count)) + const items = await enrichTracks(raw, cfg.count) return saveBatch(batches, { - id, - type: 'tracks', - createdAt: new Date().toISOString(), - trigger, + ...base, 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) + + if (type === 'singers') { + const signals = await collectSingerSignals(cfg.sources) + const library = await libraryArtists() + const likedTitles = ytLiked(60).map((r) => r.title) + const raw = await callGemini(buildSingersPrompt(signals, cfg.count, library)) + const items = await enrichSingers(raw, cfg.count, library, likedTitles) + return saveBatch(batches, { + ...base, + signalCounts: { sangare: signals.sangare.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, { - id, + ...base, type: 'mix', - createdAt: new Date().toISOString(), - trigger, signalCounts: { bibliotek: signals.bibliotek.length, spelat: signals.nyligenSpelat.length, diff --git a/backend/src/ytmusic.js b/backend/src/ytmusic.js index 052865b..108403f 100644 --- a/backend/src/ytmusic.js +++ b/backend/src/ytmusic.js @@ -60,7 +60,14 @@ export function ytmusicFlow({ config, store, fetchImpl = fetch }) { expires_in: js.expires_in, expires_at: Math.floor(Date.now() / 1000) + (js.expires_in ?? 0), } - writeFileSync(this.oauthPath, JSON.stringify(token, null, 2)) + try { + writeFileSync(this.oauthPath, JSON.stringify(token, null, 2)) + } catch (err) { + return { + status: 'error', + message: `godkänt av Google men kunde inte spara ${this.oauthPath}: ${err.code ?? err.message} (skrivrättigheter på Youtubarrs datamapp?)`, + } + } return { status: 'klar' } }, } diff --git a/backend/test/settings.test.js b/backend/test/settings.test.js index bfa8f91..6934a9b 100644 --- a/backend/test/settings.test.js +++ b/backend/test/settings.test.js @@ -4,7 +4,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { buildApp } from '../src/app.js' -import { settingsStore, effectiveConfig } from '../src/settings.js' +import { settingsStore, effectiveConfig, suggestConfig } from '../src/settings.js' import { makeTesters } from '../src/testers.js' function tmpDataDir() { @@ -44,6 +44,22 @@ test('effectiveConfig: sparat har företräde, env är fallback', () => { assert.equal(eff.gemini.model, 'gemini-2.5-flash') }) +test('suggestConfig: defaults, migrering av gamla nycklar och clamping', () => { + const def = suggestConfig({}) + assert.equal(def.mix.every, 24) + assert.equal(def.tracks.unit, 'days') + assert.equal(def.singers.unit, 'months') + + const legacy = suggestConfig({ suggest: { intervalHours: 12, trackIntervalDays: 3 } }) + assert.equal(legacy.mix.every, 12) + assert.equal(legacy.mix.unit, 'hours') + assert.equal(legacy.tracks.every, 3) + + const clamped = suggestConfig({ suggest: { mix: { count: 99, unit: 'veckor' } } }) + assert.equal(clamped.mix.count, 25) + assert.equal(clamped.mix.unit, 'hours') +}) + test('PUT + GET /api/settings maskerar hemligheter', async () => { const store = settingsStore(tmpDataDir()) const app = buildApp(baseConfig, { lidarr: {}, musicbrainz: {}, store }) diff --git a/backend/test/suggest.test.js b/backend/test/suggest.test.js index 3326f00..480ed1f 100644 --- a/backend/test/suggest.test.js +++ b/backend/test/suggest.test.js @@ -134,6 +134,39 @@ test('run(tracks): gillade + mest spelade in i prompten, egen batch-typ', async assert.equal(engine.lastRun('mix'), null) }) +test('run(singers): unika sångare i prompt, filtrering mot bibliotek + gillat', async () => { + const { engine, fetchImpl } = setup({ + geminiResponse: JSON.stringify([ + { + name: 'Floor Jansen', + motivation: 'Kraftfull sopran.', + topTracks: [ + { name: 'Gimme! Gimme! Gimme!', artist: 'ABBA' }, + { name: 'Storm', artist: 'Floor Jansen' }, + ], + relatedArtists: ['Powerwolf', 'Epica'], + }, + ]), + }) + const batch = await engine.run('manuell', 'singers') + + assert.match(fetchImpl.prompt, /SÅNGARE/) + assert.match(fetchImpl.prompt, /ABBA/, 'sångare ur gillat ska in i prompten') + assert.match(fetchImpl.prompt, /1000mods/, 'sångare ur spelat ska in i prompten') + + assert.equal(batch.type, 'singers') + const s = batch.items[0] + assert.equal(s.kind, 'singer') + assert.equal(s.topTracks.length, 1, 'gillad låt (Gimme! Gimme! Gimme!) ska filtreras bort') + assert.equal(s.topTracks[0].name, 'Storm') + assert.deepEqual( + s.relatedArtists.map((a) => a.name), + ['Epica'], + 'Powerwolf finns i biblioteket och ska filtreras bort', + ) + assert.equal(s.relatedArtists[0].mbid, 'mbid-x') +}) + 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 e5e5118..90d18c6 100644 --- a/frontend/src/components/MusicCard.vue +++ b/frontend/src/components/MusicCard.vue @@ -2,6 +2,7 @@ import { computed, ref } from 'vue' const props = defineProps({ item: { type: Object, required: true } }) +const emit = defineEmits(['detail']) const state = ref('') // '' | 'busy' | 'done' | 'fanns' | 'fel' const ytUrl = computed(() => { @@ -19,7 +20,7 @@ async function grab() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - kind: props.item.kind, + kind: props.item.kind === 'singer' ? 'artist' : props.item.kind, mbid: props.item.mbid, artistMbid: props.item.artistMbid, albumMbid: props.item.albumMbid, @@ -42,7 +43,7 @@ async function grab() {