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:
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 på de 10 senaste unika sångarna i
|
Inga genreförslag ännu — baseras på artisterna i det du gillat
|
||||||
det du gillat (YT Music) och lyssnat på (Jellyfin). Klicka in på en
|
(YT Music) och lyssnat på (Jellyfin). Klicka in på 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user