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

@@ -1,10 +1,13 @@
<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 added = ref({})
// genrer har topArtists, gamla sångar-batcher har relatedArtists
const artists = computed(() => props.item.topArtists ?? props.item.relatedArtists ?? [])
function ytLink(name, artist) {
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="modal">
<header>
<h3>{{ singer.name }}</h3>
<h3>{{ item.name }}</h3>
<button class="close" @click="emit('close')"></button>
</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>
<li v-for="(t, i) in singer.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="!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}`">
<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>
@@ -57,12 +51,21 @@ const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
</button>
</span>
</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>
<div class="foot">
<button v-if="singer.mbid" class="mini" :disabled="added.self" @click="grabArtist(singer.mbid, 'self')">
{{ stateLabel[added.self] ?? `⤓ Hämta ${singer.name} till Lidarr` }}
<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>

View File

@@ -43,7 +43,7 @@ async function grab() {
<div class="poster">
<img v-if="item.poster" :src="item.poster" alt="" loading="lazy" @error="item.poster = null" />
<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 class="meta">
<strong :title="item.name">{{ item.name }}</strong>
@@ -55,14 +55,15 @@ async function grab() {
<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>
<a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music"> YT</a>
</div>
<button v-if="item.kind === 'singer'" class="more" @click="emit('detail', item)">
Topplåtar & liknande
<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>
</article>
</template>

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
<script setup>
import { onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue'
import SingerModal from '../components/SingerModal.vue'
import DetailModal from '../components/DetailModal.vue'
const batches = ref([])
const selected = ref(null)
const loading = ref(false)
const openSinger = ref(null)
const openDetail = ref(null)
async function load() {
const res = await fetch('/api/suggest/batches')
@@ -44,7 +44,7 @@ onMounted(load)
@click="pick(b.id)"
>
<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>
</aside>
@@ -59,13 +59,13 @@ onMounted(load)
v-for="(r, i) in selected.items"
:key="`${selected.id}-${r.mbid ?? i}`"
:item="r"
@detail="openSinger = $event"
@detail="openDetail = $event"
/>
</div>
</template>
<p v-else-if="loading" class="dim">Laddar</p>
</div>
<SingerModal v-if="openSinger" :singer="openSinger" @close="openSinger = null" />
<DetailModal v-if="openDetail" :item="openDetail" @close="openDetail = null" />
</section>
</template>