Genrekort: bara Utforska + YT; popupen visar kortrutnät med artister och låtar
All checks were successful
build-and-push / build (push) Successful in 13s

Utforska-popupen renderar två MusicCard-grids (bilder, Hämta, ▶ YT) —
genrens låtar berikas nu också med artist-mbid/omslag i backend
(cachade uppslag så samma artist inte slås upp dubbelt). Äldre
batcher normaliseras i modalen.

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 03:06:04 +02:00
parent 43a41b300e
commit dca6d3ab24
4 changed files with 112 additions and 116 deletions

View File

@@ -282,15 +282,50 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
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()))
// samma artist slås ofta upp flera gånger (toppartist + låtarnas artister)
const hitCache = new Map()
const cachedHit = async (name) => {
const key = (name ?? '').toLowerCase()
if (!hitCache.has(key)) hitCache.set(key, await lidarrArtistHit(name))
return hitCache.get(key)
}
const out = []
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 hit = await lidarrArtistHit(name)
const hit = await cachedHit(name)
if (hit?.inLibrary) continue
topArtists.push({ name, mbid: hit?.mbid ?? null, poster: hit?.poster ?? null })
topArtists.push({
kind: 'artist',
name,
secondary: '',
motivation: '',
year: '',
mbid: hit?.mbid ?? null,
poster: hit?.poster ?? null,
inLibrary: false,
})
}
const topTracks = []
for (const t of (it.topTracks ?? []).filter((t) => !likedSet.has((t.name ?? '').toLowerCase())).slice(0, 5)) {
const hit = await cachedHit(t.artist ?? t.name)
topTracks.push({
kind: 'track',
name: t.name,
secondary: t.artist ?? '',
motivation: '',
year: '',
mbid: hit?.mbid ?? null,
artistMbid: hit?.mbid ?? null,
albumMbid: null,
poster: hit?.poster ?? null,
inLibrary: false,
})
}
out.push({
kind: 'genre',
name: it.name,
@@ -301,10 +336,7 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
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 ?? '' })),
topTracks,
})
}
return out

View File

@@ -165,6 +165,10 @@ test('run(genres): artister i prompt, filtrering mot bibliotek + gillat', async
'Powerwolf finns i biblioteket och ska filtreras bort',
)
assert.equal(g.topArtists[0].mbid, 'mbid-x')
assert.equal(g.topArtists[0].kind, 'artist', 'toppartister ska vara färdiga kort')
assert.equal(g.topTracks[0].kind, 'track')
assert.equal(g.topTracks[0].artistMbid, 'mbid-x', 'låtar i genren berikas med artist-mbid')
assert.equal(g.topTracks[0].poster, 'http://img/s.jpg', 'låtar i genren får omslag')
assert.equal(g.poster, 'http://img/s.jpg', 'genrekortet lånar första toppartistens omslag')
})

View File

@@ -1,73 +1,63 @@
<script setup>
import { computed, ref } from 'vue'
import { computed } from 'vue'
import MusicCard from './MusicCard.vue'
const props = defineProps({ item: { type: Object, required: true } })
const emit = defineEmits(['close'])
const added = ref({})
// genrer har topArtists, gamla sångar-batcher har relatedArtists
const artists = computed(() => props.item.topArtists ?? props.item.relatedArtists ?? [])
// nya genre-batcher har färdiga kort; äldre batcher (sångare/tidiga genrer)
// har enklare objekt — normalisera till MusicCard-format
const artistCards = computed(() =>
(props.item.topArtists ?? props.item.relatedArtists ?? []).map((a) => ({
kind: 'artist',
name: a.name,
secondary: a.secondary ?? '',
motivation: a.motivation ?? '',
year: a.year ?? '',
mbid: a.mbid ?? null,
poster: a.poster ?? null,
inLibrary: a.inLibrary ?? false,
})),
)
function ytLink(name, artist) {
return `https://music.youtube.com/search?q=${encodeURIComponent(`${artist ?? ''} ${name}`.trim())}`
}
async function grabArtist(mbid, key) {
if (!mbid) return
added.value = { ...added.value, [key]: 'busy' }
try {
const res = await fetch('/api/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'artist', mbid }),
})
const body = await res.json()
added.value = { ...added.value, [key]: res.ok ? (body.already ? 'fanns' : 'done') : 'fel' }
} catch {
added.value = { ...added.value, [key]: 'fel' }
}
}
const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
const trackCards = computed(() =>
(props.item.topTracks ?? []).map((t) => ({
kind: 'track',
name: t.name,
secondary: t.secondary ?? t.artist ?? '',
motivation: t.motivation ?? '',
year: t.year ?? '',
mbid: t.mbid ?? t.artistMbid ?? null,
artistMbid: t.artistMbid ?? t.mbid ?? null,
albumMbid: t.albumMbid ?? null,
poster: t.poster ?? null,
inLibrary: t.inLibrary ?? false,
})),
)
</script>
<template>
<div class="overlay" @click.self="emit('close')">
<div class="modal">
<header>
<h3>{{ item.name }}</h3>
<div>
<h3>{{ item.name }}</h3>
<p class="dim">{{ item.motivation }}</p>
</div>
<button class="close" @click="emit('close')"></button>
</header>
<p class="dim">{{ item.motivation }}</p>
<h4>{{ item.kind === 'genre' ? 'Toppartister' : 'Liknande artister' }} <span class="dim">(sådant du inte redan har)</span></h4>
<ul>
<li v-for="(a, i) in artists" :key="`a-${i}`">
<span class="name">{{ a.name }}</span>
<span class="row-actions">
<a class="yt" :href="ytLink(a.name)" target="_blank" rel="noopener"> YT</a>
<button v-if="a.mbid" class="mini" :disabled="added[`a-${i}`]" @click="grabArtist(a.mbid, `a-${i}`)">
{{ stateLabel[added[`a-${i}`]] ?? ' Hämta' }}
</button>
</span>
</li>
<li v-if="!artists.length" class="dim">inget kvar efter filtrering</li>
</ul>
<h4>Topplåtar</h4>
<ul>
<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>
<h4>Artister <span class="dim">(sådant du inte redan har)</span></h4>
<div v-if="artistCards.length" class="grid">
<MusicCard v-for="(a, i) in artistCards" :key="`a-${i}`" :item="a" />
</div>
<p v-else class="dim">inget kvar efter filtrering</p>
<h4>Låtar</h4>
<div v-if="trackCards.length" class="grid">
<MusicCard v-for="(t, i) in trackCards" :key="`t-${i}`" :item="t" />
</div>
<p v-else class="dim">inget kvar efter filtrering</p>
</div>
</div>
</template>
@@ -76,7 +66,7 @@ const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.65);
display: grid;
place-items: center;
z-index: 50;
@@ -84,19 +74,20 @@ const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
}
.modal {
background: var(--card);
background: var(--bg);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 1.2rem 1.4rem;
width: min(30rem, 95vw);
max-height: 85vh;
width: min(58rem, 95vw);
max-height: 88vh;
overflow-y: auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 1rem;
}
h3 {
@@ -104,61 +95,26 @@ h3 {
}
h4 {
margin: 1rem 0 0.4rem;
margin: 1.1rem 0 0.6rem;
}
.close {
background: transparent;
color: var(--text-dim);
padding: 0.2rem 0.5rem;
flex: 0 0 auto;
}
.dim {
color: var(--text-dim);
font-weight: 400;
font-size: 0.9rem;
margin: 0.2rem 0 0;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-actions {
display: flex;
gap: 0.4rem;
flex: 0 0 auto;
}
.yt {
color: #ff8080;
font-size: 0.85rem;
white-space: nowrap;
}
.mini {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.foot {
margin-top: 1.2rem;
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
gap: 0.8rem;
}
</style>

View File

@@ -53,17 +53,21 @@ async function grab() {
<p v-if="item.motivation" class="motivation">{{ item.motivation }}</p>
</div>
<div class="buttons">
<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="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 :disabled="state === 'busy'" @click="grab">
{{ state === 'busy' ? 'Lägger till' : state === 'fel' ? 'Försök igen' : ' Hämta' }}
</button>
<template v-if="item.kind === 'genre'">
<button @click="emit('detail', item)">🔍 Utforska</button>
</template>
<template v-else>
<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="!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">
{{ state === 'busy' ? 'Lägger till' : state === 'fel' ? 'Försök igen' : ' Hämta' }}
</button>
</template>
<a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music"> YT</a>
</div>
<button v-if="item.kind === 'singer' || item.kind === 'genre'" class="more" @click="emit('detail', item)">
{{ item.kind === 'genre' ? 'Toppartister & topplåtar ' : 'Topplåtar & liknande ' }}
<button v-if="item.kind === 'singer'" class="more" @click="emit('detail', item)">
Topplåtar & liknande
</button>
</article>
</template>