Sångare -> Genrer: kategorin föreslår musikgenrer med toppartister/topplåtar
All checks were successful
build-and-push / build (push) Successful in 14s
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:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user