Förslag-sidan: typ-knappar i vänsterspalten, alla fynd nyast först
All checks were successful
build-and-push / build (push) Successful in 12s

Tre knappar (Artister & album / Låtar / Genrer) ersätter batch-listan;
huvudspalten visar alla cachade fynd av vald typ i tidsordning med
nyast först, grupperade per körning med datum-avdelare. Nytt API
GET /api/suggest/items?type=X (plattar batcher med batch-metadata).

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:07:54 +02:00
parent dca6d3ab24
commit 4e89bd1986
4 changed files with 85 additions and 39 deletions

View File

@@ -235,6 +235,11 @@ export function buildApp(config, deps = {}) {
}
})
app.get('/api/suggest/items', async (req) => {
const type = SUGGEST_TYPES.includes(req.query.type) ? req.query.type : 'mix'
return suggest.allItems(type)
})
app.get('/api/suggest/batches', async () => {
return suggest.loadBatches().map((b) => ({
id: b.id,

View File

@@ -355,6 +355,16 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
return items.slice(0, limit)
},
// alla fynd av en typ, nyast först, med batch-metadata för gruppering
allItems(type = 'mix', limit = 500) {
return loadBatches()
.filter((b) => (b.type ?? 'mix') === type)
.flatMap((b) =>
b.items.map((it) => ({ ...it, batchId: b.id, batchCreatedAt: b.createdAt, trigger: b.trigger })),
)
.slice(0, limit)
},
lastRun(type = 'mix') {
return loadBatches().find((b) => (b.type ?? 'mix') === type)?.createdAt ?? null
},

View File

@@ -103,6 +103,18 @@ test('run: batch-id räknas upp och latest tar nyaste först', async () => {
assert.equal(engine.loadBatches()[0].id, b2.id)
})
test('allItems: plattar batcher nyast först med batch-metadata', async () => {
const { engine } = setup()
const b1 = await engine.run()
const b2 = await engine.run()
const all = engine.allItems('mix')
assert.equal(all.length, 2)
assert.equal(all[0].batchId, b2.id, 'nyaste batchens fynd först')
assert.equal(all[1].batchId, b1.id)
assert.equal(all[0].batchCreatedAt, b2.createdAt)
assert.equal(engine.allItems('tracks').length, 0)
})
test('run: markdown-staket runt JSON hanteras', async () => {
const { engine } = setup({
geminiResponse: '```json\n[{"type":"artist","name":"Ghost","motivation":"x"}]\n```',

View File

@@ -1,70 +1,85 @@
<script setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue'
import DetailModal from '../components/DetailModal.vue'
const batches = ref([])
const selected = ref(null)
const TYPES = [
{ key: 'mix', label: '✨ Artister & album' },
{ key: 'tracks', label: '🎵 Låtar' },
{ key: 'genres', label: '🎼 Genrer' },
]
const active = ref('mix')
const items = ref([])
const loading = ref(false)
const openDetail = ref(null)
async function load() {
const res = await fetch('/api/suggest/batches')
batches.value = await res.json()
if (batches.value.length && !selected.value) pick(batches.value[0].id)
}
async function pick(id) {
async function pick(type) {
active.value = type
loading.value = true
try {
const res = await fetch(`/api/suggest/batches/${id}`)
if (res.ok) selected.value = await res.json()
const res = await fetch(`/api/suggest/items?type=${type}`)
items.value = res.ok ? await res.json() : []
} finally {
loading.value = false
}
}
// gruppera per körning (nyast först, ordningen kommer från API:t)
const groups = computed(() => {
const out = []
for (const it of items.value) {
const last = out[out.length - 1]
if (last && last.batchId === it.batchId) last.items.push(it)
else out.push({ batchId: it.batchId, createdAt: it.batchCreatedAt, trigger: it.trigger, items: [it] })
}
return out
})
function fmt(iso) {
if (!iso) return ''
return new Date(iso).toLocaleString('sv-SE', { dateStyle: 'medium', timeStyle: 'short' })
}
onMounted(load)
onMounted(() => pick('mix'))
</script>
<template>
<section class="wrap">
<aside>
<h2>Förslag</h2>
<p v-if="!batches.length" class="dim">Inga batcher ännu generera från Upptäck-sidan.</p>
<button
v-for="b in batches"
:key="b.id"
class="batch"
:class="{ active: selected?.id === b.id }"
@click="pick(b.id)"
v-for="t in TYPES"
:key="t.key"
class="typebtn"
:class="{ active: active === t.key }"
@click="pick(t.key)"
>
<strong>#{{ b.id }}</strong> {{ fmt(b.createdAt) }}
<span class="dim">{{ { tracks: 'låtar', singers: 'sångare', genres: 'genrer' }[b.type] ?? 'artister/album' }} · {{ b.count }} förslag · {{ b.trigger }}</span>
{{ t.label }}
</button>
</aside>
<div class="content">
<template v-if="selected">
<p class="dim">
Batch #{{ selected.id }} ({{ fmt(selected.createdAt) }}) signaler:
{{ Object.entries(selected.signalCounts ?? {}).map(([k, v]) => `${k}: ${v}`).join(', ') }}
<p v-if="loading" class="dim">Laddar</p>
<p v-else-if="!groups.length" class="dim">
Inga förslag av den här typen ännu generera från Upptäck-sidan.
</p>
<template v-for="g in groups" :key="g.batchId">
<p class="dim divider">
Körning #{{ g.batchId }} · {{ fmt(g.createdAt) }} · {{ g.trigger }}
</p>
<div class="grid">
<MusicCard
v-for="(r, i) in selected.items"
:key="`${selected.id}-${r.mbid ?? i}`"
v-for="(r, i) in g.items"
:key="`${g.batchId}-${r.mbid ?? r.name}-${i}`"
:item="r"
@detail="openDetail = $event"
/>
</div>
</template>
<p v-else-if="loading" class="dim">Laddar</p>
</div>
<DetailModal v-if="openDetail" :item="openDetail" @close="openDetail = null" />
</section>
</template>
@@ -72,7 +87,7 @@ onMounted(load)
<style scoped>
.wrap {
display: grid;
grid-template-columns: 16rem 1fr;
grid-template-columns: 14rem 1fr;
gap: 1.5rem;
align-items: start;
}
@@ -95,24 +110,22 @@ aside {
top: 5rem;
}
.batch {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
.typebtn {
text-align: left;
background: var(--card);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--text);
padding: 0.6rem 0.8rem;
padding: 0.7rem 0.9rem;
font-weight: 600;
}
.batch:hover {
.typebtn:hover {
background: var(--card-hover);
}
.batch.active {
.typebtn.active {
border-color: var(--accent);
background: rgba(99, 102, 241, 0.12);
}
.dim {
@@ -120,8 +133,14 @@ aside {
font-size: 0.85rem;
}
.content .dim {
margin: 0 0 1rem;
.divider {
margin: 1.2rem 0 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 0.3rem;
}
.divider:first-of-type {
margin-top: 0;
}
.grid {