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 () => { app.get('/api/suggest/batches', async () => {
return suggest.loadBatches().map((b) => ({ return suggest.loadBatches().map((b) => ({
id: b.id, id: b.id,

View File

@@ -355,6 +355,16 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
return items.slice(0, limit) 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') { lastRun(type = 'mix') {
return loadBatches().find((b) => (b.type ?? 'mix') === type)?.createdAt ?? null 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) 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 () => { test('run: markdown-staket runt JSON hanteras', async () => {
const { engine } = setup({ const { engine } = setup({
geminiResponse: '```json\n[{"type":"artist","name":"Ghost","motivation":"x"}]\n```', geminiResponse: '```json\n[{"type":"artist","name":"Ghost","motivation":"x"}]\n```',

View File

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