From 43a41b300eab8b226e4a539e740e66f1aa04c313 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 1 Aug 2026 02:43:53 +0200 Subject: [PATCH] =?UTF-8?q?S=C3=A5ngare=20->=20Genrer:=20kategorin=20f?= =?UTF-8?q?=C3=B6resl=C3=A5r=20musikgenrer=20med=20toppartister/toppl?= =?UTF-8?q?=C3=A5tar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rättelse per Björn: tredje förslags-typen ska vara genrer, inte sångare. Gemini föreslår genrer utifrån artisterna i gillat/spelat; varje genre får 5 toppartister (filtrerade mot biblioteket, Hämta-knapp i modalen) och 5 topplåtar (filtrerade mot gillalistan, YT-länkar). Genrekortet lånar första toppartistens omslag. Gamla sångar-batcher renderas fortfarande. DetailModal generaliserad. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup --- backend/src/app.js | 6 +- backend/src/server.js | 2 +- backend/src/settings.js | 2 +- backend/src/suggest.js | 68 +++++++++---------- backend/test/settings.test.js | 2 +- backend/test/suggest.test.js | 31 +++++---- .../{SingerModal.vue => DetailModal.vue} | 41 +++++------ frontend/src/components/MusicCard.vue | 7 +- frontend/src/pages/DiscoverPage.vue | 35 +++++----- frontend/src/pages/SettingsPage.vue | 8 +-- frontend/src/pages/SuggestionsPage.vue | 10 +-- 11 files changed, 107 insertions(+), 105 deletions(-) rename frontend/src/components/{SingerModal.vue => DetailModal.vue} (74%) diff --git a/backend/src/app.js b/backend/src/app.js index 5fa5b78..f5773e6 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -18,7 +18,7 @@ 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_TYPES = ['mix', 'tracks', 'genres'] const SUGGEST_UNITS = new Set(['hours', 'days', 'months']) function sanitizeSuggest(body) { @@ -227,10 +227,10 @@ export function buildApp(config, deps = {}) { return { items: suggest.latest(cfg.mix.count, 'mix'), tracks: suggest.latest(cfg.tracks.count, 'tracks'), - singers: suggest.latest(cfg.singers.count, 'singers'), + genres: suggest.latest(cfg.genres.count, 'genres'), lastRun: suggest.lastRun('mix'), lastTrackRun: suggest.lastRun('tracks'), - lastSingersRun: suggest.lastRun('singers'), + lastGenresRun: suggest.lastRun('genres'), schedule: cfg, } }) diff --git a/backend/src/server.js b/backend/src/server.js index e7312a4..c39fefa 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -32,7 +32,7 @@ async function runIfDue(type, ms) { setInterval(async () => { const cfg = suggestConfig(app.settingsStore.load()) - for (const type of ['mix', 'tracks', 'singers']) { + for (const type of ['mix', 'tracks', 'genres']) { try { await runIfDue(type, cfg[type].every * UNIT_MS[cfg[type].unit]) } catch (err) { diff --git a/backend/src/settings.js b/backend/src/settings.js index 46c77f4..bad8b30 100644 --- a/backend/src/settings.js +++ b/backend/src/settings.js @@ -39,7 +39,7 @@ export function settingsStore(dataDir) { 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 } }, + genres: { every: 1, unit: 'months', count: 8, sources: { gillade: true, spelat: true } }, } export const UNIT_MS = { diff --git a/backend/src/suggest.js b/backend/src/suggest.js index df41339..eddc017 100644 --- a/backend/src/suggest.js +++ b/backend/src/suggest.js @@ -117,18 +117,18 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { return signals } - async function collectSingerSignals(sources) { + async function collectGenreSignals(sources) { const seen = new Set() - const singers = [] + const artister = [] const add = (name) => { const key = (name ?? '').trim().toLowerCase() if (!key || key === '?' || seen.has(key)) return seen.add(key) - singers.push(name.trim()) + 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 { sangare: singers.slice(0, 10) } + return { artister: artister.slice(0, 10) } } // ---------- prompter ---------- @@ -166,21 +166,22 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { ].join('\n') } - function buildSingersPrompt(signals, count, library) { + function buildGenresPrompt(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.', + '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":"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.', + '[{"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.', '', - `De senaste unika sångarna användaren gillat/lyssnat på: ${signals.sangare.join(', ') || '(ingen data)'}`, + `Artister användaren nyligen gillat/lyssnat på: ${signals.artister.join(', ') || '(ingen data)'}`, '', - `Redan i biblioteket (undvik): ${library.join(', ') || '(tomt)'}`, + `Redan i biblioteket (undvik i topArtists): ${library.join(', ') || '(tomt)'}`, ].join('\n') } @@ -278,35 +279,32 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { return out } - async function enrichSingers(rawItems, count, library, likedTitles) { + 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())) 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)) { + 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 rHit = await lidarrArtistHit(name) - if (rHit?.inLibrary) continue - related.push({ name, mbid: rHit?.mbid ?? null }) + const hit = await lidarrArtistHit(name) + if (hit?.inLibrary) continue + topArtists.push({ name, mbid: hit?.mbid ?? null, poster: hit?.poster ?? null }) } out.push({ - kind: 'singer', + kind: 'genre', name: it.name, secondary: '', motivation: it.motivation ?? '', year: '', - mbid: hit?.mbid ?? null, - poster: hit?.poster ?? null, + mbid: null, + poster: topArtists.find((a) => a.poster)?.poster ?? null, inLibrary: false, + topArtists, 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, + .map((t) => ({ name: t.name, artist: t.artist ?? '' })), }) } return out @@ -346,15 +344,15 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) { }) } - if (type === 'singers') { - const signals = await collectSingerSignals(cfg.sources) + 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(buildSingersPrompt(signals, cfg.count, library)) - const items = await enrichSingers(raw, cfg.count, library, likedTitles) + const raw = await callGemini(buildGenresPrompt(signals, cfg.count, library)) + const items = await enrichGenres(raw, cfg.count, library, likedTitles) return saveBatch(batches, { ...base, - signalCounts: { sangare: signals.sangare.length, bibliotek: library.length }, + signalCounts: { artister: signals.artister.length, bibliotek: library.length }, items, }) } diff --git a/backend/test/settings.test.js b/backend/test/settings.test.js index 6934a9b..6f61908 100644 --- a/backend/test/settings.test.js +++ b/backend/test/settings.test.js @@ -48,7 +48,7 @@ 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') + assert.equal(def.genres.unit, 'months') const legacy = suggestConfig({ suggest: { intervalHours: 12, trackIntervalDays: 3 } }) assert.equal(legacy.mix.every, 12) diff --git a/backend/test/suggest.test.js b/backend/test/suggest.test.js index 480ed1f..9d3377b 100644 --- a/backend/test/suggest.test.js +++ b/backend/test/suggest.test.js @@ -134,37 +134,38 @@ 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 () => { +test('run(genres): artister i prompt, filtrering mot bibliotek + gillat', async () => { const { engine, fetchImpl } = setup({ geminiResponse: JSON.stringify([ { - name: 'Floor Jansen', - motivation: 'Kraftfull sopran.', + name: 'Symphonic metal', + motivation: 'Storslaget och episkt.', + topArtists: ['Powerwolf', 'Epica'], topTracks: [ { name: 'Gimme! Gimme! Gimme!', artist: 'ABBA' }, { name: 'Storm', artist: 'Floor Jansen' }, ], - relatedArtists: ['Powerwolf', 'Epica'], }, ]), }) - const batch = await engine.run('manuell', 'singers') + const batch = await engine.run('manuell', 'genres') - 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.match(fetchImpl.prompt, /GENRER/) + assert.match(fetchImpl.prompt, /ABBA/, 'artister ur gillat ska in i prompten') + assert.match(fetchImpl.prompt, /1000mods/, 'artister 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.equal(batch.type, 'genres') + const g = batch.items[0] + assert.equal(g.kind, 'genre') + assert.equal(g.topTracks.length, 1, 'gillad låt (Gimme! Gimme! Gimme!) ska filtreras bort') + assert.equal(g.topTracks[0].name, 'Storm') assert.deepEqual( - s.relatedArtists.map((a) => a.name), + g.topArtists.map((a) => a.name), ['Epica'], 'Powerwolf finns i biblioteket och ska filtreras bort', ) - assert.equal(s.relatedArtists[0].mbid, 'mbid-x') + assert.equal(g.topArtists[0].mbid, 'mbid-x') + assert.equal(g.poster, 'http://img/s.jpg', 'genrekortet lånar första toppartistens omslag') }) test('run utan Gemini-nyckel ger begripligt fel', async () => { diff --git a/frontend/src/components/SingerModal.vue b/frontend/src/components/DetailModal.vue similarity index 74% rename from frontend/src/components/SingerModal.vue rename to frontend/src/components/DetailModal.vue index d78795e..aedd78f 100644 --- a/frontend/src/components/SingerModal.vue +++ b/frontend/src/components/DetailModal.vue @@ -1,10 +1,13 @@