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
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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```',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user