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
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:
@@ -282,15 +282,50 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
|
|||||||
async function enrichGenres(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()))
|
||||||
|
// 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 = []
|
const out = []
|
||||||
for (const it of rawItems.slice(0, count)) {
|
for (const it of rawItems.slice(0, count)) {
|
||||||
const topArtists = []
|
const topArtists = []
|
||||||
for (const name of (it.topArtists ?? []).slice(0, 5)) {
|
for (const name of (it.topArtists ?? []).slice(0, 5)) {
|
||||||
if (libSet.has((name ?? '').toLowerCase())) continue
|
if (libSet.has((name ?? '').toLowerCase())) continue
|
||||||
const hit = await lidarrArtistHit(name)
|
const hit = await cachedHit(name)
|
||||||
if (hit?.inLibrary) continue
|
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({
|
out.push({
|
||||||
kind: 'genre',
|
kind: 'genre',
|
||||||
name: it.name,
|
name: it.name,
|
||||||
@@ -301,10 +336,7 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
|
|||||||
poster: topArtists.find((a) => a.poster)?.poster ?? null,
|
poster: topArtists.find((a) => a.poster)?.poster ?? null,
|
||||||
inLibrary: false,
|
inLibrary: false,
|
||||||
topArtists,
|
topArtists,
|
||||||
topTracks: (it.topTracks ?? [])
|
topTracks,
|
||||||
.filter((t) => !likedSet.has((t.name ?? '').toLowerCase()))
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((t) => ({ name: t.name, artist: t.artist ?? '' })),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ test('run(genres): artister i prompt, filtrering mot bibliotek + gillat', async
|
|||||||
'Powerwolf finns i biblioteket och ska filtreras bort',
|
'Powerwolf finns i biblioteket och ska filtreras bort',
|
||||||
)
|
)
|
||||||
assert.equal(g.topArtists[0].mbid, 'mbid-x')
|
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')
|
assert.equal(g.poster, 'http://img/s.jpg', 'genrekortet lånar första toppartistens omslag')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,63 @@
|
|||||||
<script setup>
|
<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 props = defineProps({ item: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
const added = ref({})
|
|
||||||
|
|
||||||
// genrer har topArtists, gamla sångar-batcher har relatedArtists
|
// nya genre-batcher har färdiga kort; äldre batcher (sångare/tidiga genrer)
|
||||||
const artists = computed(() => props.item.topArtists ?? props.item.relatedArtists ?? [])
|
// 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) {
|
const trackCards = computed(() =>
|
||||||
return `https://music.youtube.com/search?q=${encodeURIComponent(`${artist ?? ''} ${name}`.trim())}`
|
(props.item.topTracks ?? []).map((t) => ({
|
||||||
}
|
kind: 'track',
|
||||||
|
name: t.name,
|
||||||
async function grabArtist(mbid, key) {
|
secondary: t.secondary ?? t.artist ?? '',
|
||||||
if (!mbid) return
|
motivation: t.motivation ?? '',
|
||||||
added.value = { ...added.value, [key]: 'busy' }
|
year: t.year ?? '',
|
||||||
try {
|
mbid: t.mbid ?? t.artistMbid ?? null,
|
||||||
const res = await fetch('/api/add', {
|
artistMbid: t.artistMbid ?? t.mbid ?? null,
|
||||||
method: 'POST',
|
albumMbid: t.albumMbid ?? null,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
poster: t.poster ?? null,
|
||||||
body: JSON.stringify({ kind: 'artist', mbid }),
|
inLibrary: t.inLibrary ?? false,
|
||||||
})
|
})),
|
||||||
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' }
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="overlay" @click.self="emit('close')">
|
<div class="overlay" @click.self="emit('close')">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<header>
|
<header>
|
||||||
<h3>{{ item.name }}</h3>
|
<div>
|
||||||
|
<h3>{{ item.name }}</h3>
|
||||||
|
<p class="dim">{{ item.motivation }}</p>
|
||||||
|
</div>
|
||||||
<button class="close" @click="emit('close')">✕</button>
|
<button class="close" @click="emit('close')">✕</button>
|
||||||
</header>
|
</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>
|
<h4>Artister <span class="dim">(sådant du inte redan har)</span></h4>
|
||||||
<ul>
|
<div v-if="artistCards.length" class="grid">
|
||||||
<li v-for="(a, i) in artists" :key="`a-${i}`">
|
<MusicCard v-for="(a, i) in artistCards" :key="`a-${i}`" :item="a" />
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -76,7 +66,7 @@ const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
|
|||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.65);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
@@ -84,19 +74,20 @@ const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--card);
|
background: var(--bg);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
padding: 1.2rem 1.4rem;
|
padding: 1.2rem 1.4rem;
|
||||||
width: min(30rem, 95vw);
|
width: min(58rem, 95vw);
|
||||||
max-height: 85vh;
|
max-height: 88vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@@ -104,61 +95,26 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
margin: 1rem 0 0.4rem;
|
margin: 1.1rem 0 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dim {
|
.dim {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
.grid {
|
||||||
list-style: none;
|
display: grid;
|
||||||
margin: 0;
|
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
|
||||||
padding: 0;
|
gap: 0.8rem;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -53,17 +53,21 @@ async function grab() {
|
|||||||
<p v-if="item.motivation" class="motivation">{{ item.motivation }}</p>
|
<p v-if="item.motivation" class="motivation">{{ item.motivation }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button v-if="item.inLibrary || state === 'fanns'" class="have" disabled>✓ I biblioteket</button>
|
<template v-if="item.kind === 'genre'">
|
||||||
<button v-else-if="state === 'done'" class="have" disabled>✓ Hämtas</button>
|
<button @click="emit('detail', item)">🔍 Utforska</button>
|
||||||
<button v-else-if="item.kind === 'genre'" class="have" disabled>utforska nedan</button>
|
</template>
|
||||||
<button v-else-if="!item.mbid" class="have" disabled title="hittades inte i Lidarrs metadata">går ej att hämta</button>
|
<template v-else>
|
||||||
<button v-else :disabled="state === 'busy'" @click="grab">
|
<button v-if="item.inLibrary || state === 'fanns'" class="have" disabled>✓ I biblioteket</button>
|
||||||
{{ state === 'busy' ? 'Lägger till…' : state === 'fel' ? 'Försök igen' : '⤓ Hämta' }}
|
<button v-else-if="state === 'done'" class="have" disabled>✓ Hämtas</button>
|
||||||
</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>
|
<a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music">▶ YT</a>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="item.kind === 'singer' || item.kind === 'genre'" class="more" @click="emit('detail', item)">
|
<button v-if="item.kind === 'singer'" class="more" @click="emit('detail', item)">
|
||||||
{{ item.kind === 'genre' ? 'Toppartister & topplåtar →' : 'Topplåtar & liknande →' }}
|
Topplåtar & liknande →
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user