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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 på de 10 senaste unika sångarna i
|
||||
det du gillat (YT Music) och lyssnat på (Jellyfin). Klicka in på en
|
||||
sångare för topplåtar och liknande artister, filtrerat mot det du
|
||||
redan har.
|
||||
Inga genreförslag ännu — baseras på artisterna i det du gillat
|
||||
(YT Music) och lyssnat på (Jellyfin). Klicka in på 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>
|
||||
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user