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

@@ -18,7 +18,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OPEN_ROUTES = new Set(['/api/health', '/api/login', '/api/session']) 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']) const SUGGEST_UNITS = new Set(['hours', 'days', 'months'])
function sanitizeSuggest(body) { function sanitizeSuggest(body) {
@@ -227,10 +227,10 @@ export function buildApp(config, deps = {}) {
return { return {
items: suggest.latest(cfg.mix.count, 'mix'), items: suggest.latest(cfg.mix.count, 'mix'),
tracks: suggest.latest(cfg.tracks.count, 'tracks'), 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'), lastRun: suggest.lastRun('mix'),
lastTrackRun: suggest.lastRun('tracks'), lastTrackRun: suggest.lastRun('tracks'),
lastSingersRun: suggest.lastRun('singers'), lastGenresRun: suggest.lastRun('genres'),
schedule: cfg, schedule: cfg,
} }
}) })

View File

@@ -32,7 +32,7 @@ async function runIfDue(type, ms) {
setInterval(async () => { setInterval(async () => {
const cfg = suggestConfig(app.settingsStore.load()) const cfg = suggestConfig(app.settingsStore.load())
for (const type of ['mix', 'tracks', 'singers']) { for (const type of ['mix', 'tracks', 'genres']) {
try { try {
await runIfDue(type, cfg[type].every * UNIT_MS[cfg[type].unit]) await runIfDue(type, cfg[type].every * UNIT_MS[cfg[type].unit])
} catch (err) { } catch (err) {

View File

@@ -39,7 +39,7 @@ export function settingsStore(dataDir) {
const SUGGEST_DEFAULTS = { const SUGGEST_DEFAULTS = {
mix: { every: 24, unit: 'hours', count: 10, sources: { bibliotek: true, spelat: true, tillagt: true } }, 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 } }, 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 = { export const UNIT_MS = {

View File

@@ -117,18 +117,18 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
return signals return signals
} }
async function collectSingerSignals(sources) { async function collectGenreSignals(sources) {
const seen = new Set() const seen = new Set()
const singers = [] const artister = []
const add = (name) => { const add = (name) => {
const key = (name ?? '').trim().toLowerCase() const key = (name ?? '').trim().toLowerCase()
if (!key || key === '?' || seen.has(key)) return if (!key || key === '?' || seen.has(key)) return
seen.add(key) 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.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) 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 ---------- // ---------- prompter ----------
@@ -166,21 +166,22 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
].join('\n') ].join('\n')
} }
function buildSingersPrompt(signals, count, library) { function buildGenresPrompt(signals, count, library) {
return [ return [
'Du är en kunnig musikrekommendatör med fokus på SÅNGARE/VOKALISTER.', 'Du är en kunnig musikrekommendatör med fokus på GENRER/MUSIKSTILAR.',
`Utifrån sångarna nedan, föreslå exakt ${count} ANDRA sångare/vokalister`, `Utifrån artisterna nedan, föreslå exakt ${count} musikgenrer eller`,
'som användaren troligen skulle uppskatta (liknande röst, genre eller', 'subgenrer som användaren verkar gilla eller borde utforska — blanda',
'känsla). Föreslå INTE någon som redan finns i listorna nedan.', 'kärngenrer ur lyssningen med angränsande stilar som breddar.',
'Svara ENBART med en JSON-array i formatet:', 'Svara ENBART med en JSON-array i formatet:',
'[{"name":"sångarens namn","motivation":"kort svensk mening",', '[{"name":"genrens namn","motivation":"kort svensk mening",',
'"topTracks":[{"name":"låttitel","artist":"artist/band"}],', '"topArtists":["artistnamn"],',
'"relatedArtists":["artistnamn"]}]', '"topTracks":[{"name":"låttitel","artist":"artist/band"}]}]',
'med 5 topTracks och 3 relatedArtists per sångare.', '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') ].join('\n')
} }
@@ -278,35 +279,32 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
return out 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 libSet = new Set(library.map((n) => n.toLowerCase()))
const likedSet = new Set(likedTitles.map((t) => t.toLowerCase())) const likedSet = new Set(likedTitles.map((t) => t.toLowerCase()))
const out = [] const out = []
for (const it of rawItems.slice(0, count + 3)) { for (const it of rawItems.slice(0, count)) {
if (out.length >= count) break const topArtists = []
const hit = await lidarrArtistHit(it.name) for (const name of (it.topArtists ?? []).slice(0, 5)) {
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 if (libSet.has((name ?? '').toLowerCase())) continue
const rHit = await lidarrArtistHit(name) const hit = await lidarrArtistHit(name)
if (rHit?.inLibrary) continue if (hit?.inLibrary) continue
related.push({ name, mbid: rHit?.mbid ?? null }) topArtists.push({ name, mbid: hit?.mbid ?? null, poster: hit?.poster ?? null })
} }
out.push({ out.push({
kind: 'singer', kind: 'genre',
name: it.name, name: it.name,
secondary: '', secondary: '',
motivation: it.motivation ?? '', motivation: it.motivation ?? '',
year: '', year: '',
mbid: hit?.mbid ?? null, mbid: null,
poster: hit?.poster ?? null, poster: topArtists.find((a) => a.poster)?.poster ?? null,
inLibrary: false, inLibrary: false,
topArtists,
topTracks: (it.topTracks ?? []) topTracks: (it.topTracks ?? [])
.filter((t) => !likedSet.has((t.name ?? '').toLowerCase())) .filter((t) => !likedSet.has((t.name ?? '').toLowerCase()))
.slice(0, 5) .slice(0, 5)
.map((t) => ({ name: t.name, artist: t.artist ?? it.name })), .map((t) => ({ name: t.name, artist: t.artist ?? '' })),
relatedArtists: related,
}) })
} }
return out return out
@@ -346,15 +344,15 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
}) })
} }
if (type === 'singers') { if (type === 'genres') {
const signals = await collectSingerSignals(cfg.sources) const signals = await collectGenreSignals(cfg.sources)
const library = await libraryArtists() const library = await libraryArtists()
const likedTitles = ytLiked(60).map((r) => r.title) const likedTitles = ytLiked(60).map((r) => r.title)
const raw = await callGemini(buildSingersPrompt(signals, cfg.count, library)) const raw = await callGemini(buildGenresPrompt(signals, cfg.count, library))
const items = await enrichSingers(raw, cfg.count, library, likedTitles) const items = await enrichGenres(raw, cfg.count, library, likedTitles)
return saveBatch(batches, { return saveBatch(batches, {
...base, ...base,
signalCounts: { sangare: signals.sangare.length, bibliotek: library.length }, signalCounts: { artister: signals.artister.length, bibliotek: library.length },
items, items,
}) })
} }

View File

@@ -48,7 +48,7 @@ test('suggestConfig: defaults, migrering av gamla nycklar och clamping', () => {
const def = suggestConfig({}) const def = suggestConfig({})
assert.equal(def.mix.every, 24) assert.equal(def.mix.every, 24)
assert.equal(def.tracks.unit, 'days') 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 } }) const legacy = suggestConfig({ suggest: { intervalHours: 12, trackIntervalDays: 3 } })
assert.equal(legacy.mix.every, 12) assert.equal(legacy.mix.every, 12)

View File

@@ -134,37 +134,38 @@ test('run(tracks): gillade + mest spelade in i prompten, egen batch-typ', async
assert.equal(engine.lastRun('mix'), null) 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({ const { engine, fetchImpl } = setup({
geminiResponse: JSON.stringify([ geminiResponse: JSON.stringify([
{ {
name: 'Floor Jansen', name: 'Symphonic metal',
motivation: 'Kraftfull sopran.', motivation: 'Storslaget och episkt.',
topArtists: ['Powerwolf', 'Epica'],
topTracks: [ topTracks: [
{ name: 'Gimme! Gimme! Gimme!', artist: 'ABBA' }, { name: 'Gimme! Gimme! Gimme!', artist: 'ABBA' },
{ name: 'Storm', artist: 'Floor Jansen' }, { 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, /GENRER/)
assert.match(fetchImpl.prompt, /ABBA/, 'sångare ur gillat ska in i prompten') assert.match(fetchImpl.prompt, /ABBA/, 'artister ur gillat ska in i prompten')
assert.match(fetchImpl.prompt, /1000mods/, 'sångare ur spelat ska in i prompten') assert.match(fetchImpl.prompt, /1000mods/, 'artister ur spelat ska in i prompten')
assert.equal(batch.type, 'singers') assert.equal(batch.type, 'genres')
const s = batch.items[0] const g = batch.items[0]
assert.equal(s.kind, 'singer') assert.equal(g.kind, 'genre')
assert.equal(s.topTracks.length, 1, 'gillad låt (Gimme! Gimme! Gimme!) ska filtreras bort') assert.equal(g.topTracks.length, 1, 'gillad låt (Gimme! Gimme! Gimme!) ska filtreras bort')
assert.equal(s.topTracks[0].name, 'Storm') assert.equal(g.topTracks[0].name, 'Storm')
assert.deepEqual( assert.deepEqual(
s.relatedArtists.map((a) => a.name), g.topArtists.map((a) => a.name),
['Epica'], ['Epica'],
'Powerwolf finns i biblioteket och ska filtreras bort', '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 () => { test('run utan Gemini-nyckel ger begripligt fel', async () => {

View File

@@ -1,10 +1,13 @@
<script setup> <script setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
const props = defineProps({ singer: { type: Object, required: true } }) const props = defineProps({ item: { type: Object, required: true } })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
const added = ref({}) const added = ref({})
// genrer har topArtists, gamla sångar-batcher har relatedArtists
const artists = computed(() => props.item.topArtists ?? props.item.relatedArtists ?? [])
function ytLink(name, artist) { function ytLink(name, artist) {
return `https://music.youtube.com/search?q=${encodeURIComponent(`${artist ?? ''} ${name}`.trim())}` return `https://music.youtube.com/search?q=${encodeURIComponent(`${artist ?? ''} ${name}`.trim())}`
} }
@@ -32,23 +35,14 @@ const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
<div class="overlay" @click.self="emit('close')"> <div class="overlay" @click.self="emit('close')">
<div class="modal"> <div class="modal">
<header> <header>
<h3>{{ singer.name }}</h3> <h3>{{ item.name }}</h3>
<button class="close" @click="emit('close')"></button> <button class="close" @click="emit('close')"></button>
</header> </header>
<p class="dim">{{ singer.motivation }}</p> <p class="dim">{{ item.motivation }}</p>
<h4>Topplåtar <span class="dim">(sådant du inte redan har)</span></h4> <h4>{{ item.kind === 'genre' ? 'Toppartister' : 'Liknande artister' }} <span class="dim">(sådant du inte redan har)</span></h4>
<ul> <ul>
<li v-for="(t, i) in singer.topTracks" :key="`t-${i}`"> <li v-for="(a, i) in artists" :key="`a-${i}`">
<span class="name">{{ t.name }} <span class="dim"> {{ t.artist }}</span></span>
<a class="yt" :href="ytLink(t.name, t.artist)" target="_blank" rel="noopener"> YT</a>
</li>
<li v-if="!singer.topTracks?.length" class="dim">inget kvar efter filtrering</li>
</ul>
<h4>Liknande artister</h4>
<ul>
<li v-for="(a, i) in singer.relatedArtists" :key="`a-${i}`">
<span class="name">{{ a.name }}</span> <span class="name">{{ a.name }}</span>
<span class="row-actions"> <span class="row-actions">
<a class="yt" :href="ytLink(a.name)" target="_blank" rel="noopener"> YT</a> <a class="yt" :href="ytLink(a.name)" target="_blank" rel="noopener"> YT</a>
@@ -57,12 +51,21 @@ const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
</button> </button>
</span> </span>
</li> </li>
<li v-if="!singer.relatedArtists?.length" class="dim">inget kvar efter filtrering</li> <li v-if="!artists.length" class="dim">inget kvar efter filtrering</li>
</ul> </ul>
<div class="foot"> <h4>Topplåtar</h4>
<button v-if="singer.mbid" class="mini" :disabled="added.self" @click="grabArtist(singer.mbid, 'self')"> <ul>
{{ stateLabel[added.self] ?? `⤓ Hämta ${singer.name} till Lidarr` }} <li v-for="(t, i) in item.topTracks" :key="`t-${i}`">
<span class="name">{{ t.name }} <span class="dim"> {{ t.artist }}</span></span>
<a class="yt" :href="ytLink(t.name, t.artist)" target="_blank" rel="noopener"> YT</a>
</li>
<li v-if="!item.topTracks?.length" class="dim">inget kvar efter filtrering</li>
</ul>
<div v-if="item.mbid" class="foot">
<button class="mini" :disabled="added.self" @click="grabArtist(item.mbid, 'self')">
{{ stateLabel[added.self] ?? `⤓ Hämta ${item.name} till Lidarr` }}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -43,7 +43,7 @@ async function grab() {
<div class="poster"> <div class="poster">
<img v-if="item.poster" :src="item.poster" alt="" loading="lazy" @error="item.poster = null" /> <img v-if="item.poster" :src="item.poster" alt="" loading="lazy" @error="item.poster = null" />
<span v-else class="note"></span> <span v-else class="note"></span>
<span class="kind">{{ { artist: 'Artist', album: 'Album', track: 'Låt', singer: 'Sångare' }[item.kind] }}</span> <span class="kind">{{ { artist: 'Artist', album: 'Album', track: 'Låt', singer: 'Sångare', genre: 'Genre' }[item.kind] }}</span>
</div> </div>
<div class="meta"> <div class="meta">
<strong :title="item.name">{{ item.name }}</strong> <strong :title="item.name">{{ item.name }}</strong>
@@ -55,14 +55,15 @@ async function grab() {
<div class="buttons"> <div class="buttons">
<button v-if="item.inLibrary || state === 'fanns'" class="have" disabled> I biblioteket</button> <button v-if="item.inLibrary || state === 'fanns'" class="have" disabled> I biblioteket</button>
<button v-else-if="state === 'done'" class="have" disabled> Hämtas</button> <button v-else-if="state === 'done'" class="have" disabled> Hämtas</button>
<button v-else-if="item.kind === 'genre'" class="have" disabled>utforska nedan</button>
<button v-else-if="!item.mbid" class="have" disabled title="hittades inte i Lidarrs metadata">går ej att hämta</button> <button v-else-if="!item.mbid" class="have" disabled title="hittades inte i Lidarrs metadata">går ej att hämta</button>
<button v-else :disabled="state === 'busy'" @click="grab"> <button v-else :disabled="state === 'busy'" @click="grab">
{{ state === 'busy' ? 'Lägger till' : state === 'fel' ? 'Försök igen' : ' Hämta' }} {{ state === 'busy' ? 'Lägger till' : state === 'fel' ? 'Försök igen' : ' Hämta' }}
</button> </button>
<a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music"> YT</a> <a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music"> YT</a>
</div> </div>
<button v-if="item.kind === 'singer'" class="more" @click="emit('detail', item)"> <button v-if="item.kind === 'singer' || item.kind === 'genre'" class="more" @click="emit('detail', item)">
Topplåtar & liknande {{ item.kind === 'genre' ? 'Toppartister & topplåtar ' : 'Topplåtar & liknande ' }}
</button> </button>
</article> </article>
</template> </template>

View File

@@ -1,18 +1,18 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue' import MusicCard from '../components/MusicCard.vue'
import SingerModal from '../components/SingerModal.vue' import DetailModal from '../components/DetailModal.vue'
const items = ref([]) const items = ref([])
const tracks = ref([]) const tracks = ref([])
const singers = ref([]) const genres = ref([])
const lastRun = ref(null) const lastRun = ref(null)
const lastTrackRun = ref(null) const lastTrackRun = ref(null)
const lastSingersRun = ref(null) const lastGenresRun = ref(null)
const schedule = ref(null) const schedule = ref(null)
const busy = ref({}) const busy = ref({})
const error = ref('') const error = ref('')
const openSinger = ref(null) const openDetail = ref(null)
const UNIT_LABEL = { hours: 'timme', days: 'dag', months: 'månad' } const UNIT_LABEL = { hours: 'timme', days: 'dag', months: 'månad' }
@@ -27,10 +27,10 @@ async function load() {
const body = await res.json() const body = await res.json()
items.value = body.items items.value = body.items
tracks.value = body.tracks tracks.value = body.tracks
singers.value = body.singers genres.value = body.genres
lastRun.value = body.lastRun lastRun.value = body.lastRun
lastTrackRun.value = body.lastTrackRun lastTrackRun.value = body.lastTrackRun
lastSingersRun.value = body.lastSingersRun lastGenresRun.value = body.lastGenresRun
schedule.value = body.schedule schedule.value = body.schedule
} }
@@ -115,33 +115,32 @@ onMounted(load)
</div> </div>
<div class="head tracks-head"> <div class="head tracks-head">
<h2>Sångare</h2> <h2>Genrer</h2>
<div class="actions"> <div class="actions">
<span class="dim">Senaste körning: {{ fmt(lastSingersRun) }} · {{ scheduleText('singers') }}</span> <span class="dim">Senaste körning: {{ fmt(lastGenresRun) }} · {{ scheduleText('genres') }}</span>
<button :disabled="busy.singers" @click="generate('singers')"> <button :disabled="busy.genres" @click="generate('genres')">
{{ busy.singers ? 'Genererar' : '🎤 Generera sångare' }} {{ busy.genres ? 'Genererar' : '🎼 Generera genrer' }}
</button> </button>
</div> </div>
</div> </div>
<div v-if="singers.length" class="grid"> <div v-if="genres.length" class="grid">
<MusicCard <MusicCard
v-for="(r, i) in singers" v-for="(r, i) in genres"
:key="`s-${r.mbid ?? r.name}-${i}`" :key="`s-${r.mbid ?? r.name}-${i}`"
:item="r" :item="r"
@detail="openSinger = $event" @detail="openDetail = $event"
/> />
</div> </div>
<div v-else class="empty small"> <div v-else class="empty small">
<p class="dim"> <p class="dim">
Inga sångarförslag ännu baseras de 10 senaste unika sångarna i Inga genreförslag ännu baseras artisterna i det du gillat
det du gillat (YT Music) och lyssnat (Jellyfin). Klicka in en (YT Music) och lyssnat (Jellyfin). Klicka in en genre för
sångare för topplåtar och liknande artister, filtrerat mot det du toppartister och topplåtar, filtrerat mot det du redan har.
redan har.
</p> </p>
</div> </div>
<SingerModal v-if="openSinger" :singer="openSinger" @close="openSinger = null" /> <DetailModal v-if="openDetail" :item="openDetail" @close="openDetail = null" />
</section> </section>
</template> </template>

View File

@@ -23,11 +23,11 @@ const SUGGEST_META = {
mestSpelade: 'Mest spelade (Jellyfin)', mestSpelade: 'Mest spelade (Jellyfin)',
}, },
}, },
singers: { genres: {
title: 'Sångare', title: 'Genrer',
sources: { sources: {
gillade: 'Sångare ur gillade låtar (YT Music)', gillade: 'Artister ur gillade låtar (YT Music)',
spelat: 'Sångare ur spelade låtar (Jellyfin)', spelat: 'Artister ur spelade låtar (Jellyfin)',
}, },
}, },
} }

View File

@@ -1,12 +1,12 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue' import MusicCard from '../components/MusicCard.vue'
import SingerModal from '../components/SingerModal.vue' import DetailModal from '../components/DetailModal.vue'
const batches = ref([]) const batches = ref([])
const selected = ref(null) const selected = ref(null)
const loading = ref(false) const loading = ref(false)
const openSinger = ref(null) const openDetail = ref(null)
async function load() { async function load() {
const res = await fetch('/api/suggest/batches') const res = await fetch('/api/suggest/batches')
@@ -44,7 +44,7 @@ onMounted(load)
@click="pick(b.id)" @click="pick(b.id)"
> >
<strong>#{{ b.id }}</strong> {{ fmt(b.createdAt) }} <strong>#{{ b.id }}</strong> {{ fmt(b.createdAt) }}
<span class="dim">{{ { tracks: 'låtar', singers: 'sångare' }[b.type] ?? 'artister/album' }} · {{ b.count }} förslag · {{ b.trigger }}</span> <span class="dim">{{ { tracks: 'låtar', singers: 'sångare', genres: 'genrer' }[b.type] ?? 'artister/album' }} · {{ b.count }} förslag · {{ b.trigger }}</span>
</button> </button>
</aside> </aside>
@@ -59,13 +59,13 @@ onMounted(load)
v-for="(r, i) in selected.items" v-for="(r, i) in selected.items"
:key="`${selected.id}-${r.mbid ?? i}`" :key="`${selected.id}-${r.mbid ?? i}`"
:item="r" :item="r"
@detail="openSinger = $event" @detail="openDetail = $event"
/> />
</div> </div>
</template> </template>
<p v-else-if="loading" class="dim">Laddar</p> <p v-else-if="loading" class="dim">Laddar</p>
</div> </div>
<SingerModal v-if="openSinger" :singer="openSinger" @close="openSinger = null" /> <DetailModal v-if="openDetail" :item="openDetail" @close="openDetail = null" />
</section> </section>
</template> </template>