Sångare -> Genrer: kategorin föreslår musikgenrer med toppartister/topplåtar
All checks were successful
build-and-push / build (push) Successful in 14s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
2026-08-01 02:43:53 +02:00
parent 3ae0289f44
commit 43a41b300e
11 changed files with 107 additions and 105 deletions

View File

@@ -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,
})
}