Sångare-förslag + schema/antal/källor per typ + YT Music-skrivfix
Some checks failed
build-and-push / build (push) Failing after 12s

- Ny batch-typ 'singers': baseras på de 10 senaste unika sångarna ur
  gillat (YT Music) + spelat (Jellyfin); Gemini ger per sångare 5
  topplåtar + 3 relaterade artister, filtrerade mot Lidarr-biblioteket
  och gillalistan; klick på kortet öppnar modal med YT-länkar och
  Hämta-knappar
- Förslags-scheman per typ: intervall med enhet (timmar/dagar/månader),
  antal förslag (1-25) och källo-toggles; gamla intervalHours/
  trackIntervalDays migreras automatiskt; schemaläggaren kör alla tre
- Inställningar: Förslag-kortet omgjort till hopfällbara sektioner per
  typ (details/summary) så det inte blir överväldigande
- ytmusic: tydligt fel om oauth.json inte kan skrivas (root-ägd mapp
  var orsaken till 'inte kopplad än' — ägarskap fixat på Pi5)
- 28 tester gröna

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 02:19:58 +02:00
parent 6ff8e4d7ca
commit ac93b25827
12 changed files with 714 additions and 228 deletions

View File

@@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
const props = defineProps({ item: { type: Object, required: true } })
const emit = defineEmits(['detail'])
const state = ref('') // '' | 'busy' | 'done' | 'fanns' | 'fel'
const ytUrl = computed(() => {
@@ -19,7 +20,7 @@ async function grab() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: props.item.kind,
kind: props.item.kind === 'singer' ? 'artist' : props.item.kind,
mbid: props.item.mbid,
artistMbid: props.item.artistMbid,
albumMbid: props.item.albumMbid,
@@ -42,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' }[item.kind] }}</span>
<span class="kind">{{ { artist: 'Artist', album: 'Album', track: 'Låt', singer: 'Sångare' }[item.kind] }}</span>
</div>
<div class="meta">
<strong :title="item.name">{{ item.name }}</strong>
@@ -60,6 +61,9 @@ async function grab() {
</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>
</article>
</template>
@@ -174,4 +178,15 @@ async function grab() {
.yt:hover {
background: rgba(255, 60, 60, 0.12);
}
.more {
background: rgba(99, 102, 241, 0.15);
color: var(--accent-hover);
font-size: 0.85rem;
padding: 0.45rem;
}
.more:hover {
background: rgba(99, 102, 241, 0.3);
}
</style>

View File

@@ -0,0 +1,161 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({ singer: { type: Object, required: true } })
const emit = defineEmits(['close'])
const added = ref({})
function ytLink(name, artist) {
return `https://music.youtube.com/search?q=${encodeURIComponent(`${artist ?? ''} ${name}`.trim())}`
}
async function grabArtist(mbid, key) {
if (!mbid) return
added.value = { ...added.value, [key]: 'busy' }
try {
const res = await fetch('/api/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'artist', mbid }),
})
const body = await res.json()
added.value = { ...added.value, [key]: res.ok ? (body.already ? 'fanns' : 'done') : 'fel' }
} catch {
added.value = { ...added.value, [key]: 'fel' }
}
}
const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
</script>
<template>
<div class="overlay" @click.self="emit('close')">
<div class="modal">
<header>
<h3>{{ singer.name }}</h3>
<button class="close" @click="emit('close')"></button>
</header>
<p class="dim">{{ singer.motivation }}</p>
<h4>Topplåtar <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}`">
<span class="name">{{ a.name }}</span>
<span class="row-actions">
<a class="yt" :href="ytLink(a.name)" target="_blank" rel="noopener"> YT</a>
<button v-if="a.mbid" class="mini" :disabled="added[`a-${i}`]" @click="grabArtist(a.mbid, `a-${i}`)">
{{ stateLabel[added[`a-${i}`]] ?? ' Hämta' }}
</button>
</span>
</li>
<li v-if="!singer.relatedArtists?.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` }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: grid;
place-items: center;
z-index: 50;
padding: 1rem;
}
.modal {
background: var(--card);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 1.2rem 1.4rem;
width: min(30rem, 95vw);
max-height: 85vh;
overflow-y: auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
h3 {
margin: 0;
}
h4 {
margin: 1rem 0 0.4rem;
}
.close {
background: transparent;
color: var(--text-dim);
padding: 0.2rem 0.5rem;
}
.dim {
color: var(--text-dim);
font-weight: 400;
font-size: 0.9rem;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-actions {
display: flex;
gap: 0.4rem;
flex: 0 0 auto;
}
.yt {
color: #ff8080;
font-size: 0.85rem;
white-space: nowrap;
}
.mini {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.foot {
margin-top: 1.2rem;
}
</style>

View File

@@ -1,25 +1,37 @@
<script setup>
import { onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue'
import SingerModal from '../components/SingerModal.vue'
const items = ref([])
const tracks = ref([])
const singers = ref([])
const lastRun = ref(null)
const lastTrackRun = ref(null)
const intervalHours = ref(24)
const trackIntervalDays = ref(7)
const lastSingersRun = ref(null)
const schedule = ref(null)
const busy = ref({})
const error = ref('')
const openSinger = ref(null)
const UNIT_LABEL = { hours: 'timme', days: 'dag', months: 'månad' }
function scheduleText(type) {
const cfg = schedule.value?.[type]
if (!cfg || !cfg.every) return 'schema: av'
return `schema: var ${cfg.every}:e ${UNIT_LABEL[cfg.unit] ?? cfg.unit}`
}
async function load() {
const res = await fetch('/api/suggest/latest')
const body = await res.json()
items.value = body.items
tracks.value = body.tracks
singers.value = body.singers
lastRun.value = body.lastRun
lastTrackRun.value = body.lastTrackRun
intervalHours.value = body.intervalHours
trackIntervalDays.value = body.trackIntervalDays
lastSingersRun.value = body.lastSingersRun
schedule.value = body.schedule
}
async function generate(type) {
@@ -57,11 +69,7 @@ onMounted(load)
<div class="head">
<h2>Upptäck</h2>
<div class="actions">
<span class="dim">
Senaste körning: {{ fmt(lastRun) }}
<template v-if="intervalHours"> · schema: var {{ intervalHours }}:e timme</template>
<template v-else> · schema: av</template>
</span>
<span class="dim">Senaste körning: {{ fmt(lastRun) }} · {{ scheduleText('mix') }}</span>
<router-link class="link" :to="{ name: 'suggestions' }">Alla förslag </router-link>
<button :disabled="busy.mix" @click="generate('mix')">
{{ busy.mix ? 'Genererar (kan ta en halv minut)' : ' Generera nya förslag' }}
@@ -88,11 +96,7 @@ onMounted(load)
<div class="head tracks-head">
<h2>Veckans låtar</h2>
<div class="actions">
<span class="dim">
Senaste körning: {{ fmt(lastTrackRun) }}
<template v-if="trackIntervalDays"> · schema: var {{ trackIntervalDays }}:e dag</template>
<template v-else> · schema: av</template>
</span>
<span class="dim">Senaste körning: {{ fmt(lastTrackRun) }} · {{ scheduleText('tracks') }}</span>
<button :disabled="busy.tracks" @click="generate('tracks')">
{{ busy.tracks ? 'Genererar' : '🎵 Generera låtförslag' }}
</button>
@@ -104,11 +108,40 @@ onMounted(load)
</div>
<div v-else class="empty small">
<p class="dim">
Inga låtförslag ännu genereras automatiskt varje vecka utifrån dina
senast gillade låtar (YT Music) och mest spelade (Jellyfin), eller
direkt via knappen.
Inga låtförslag ännu genereras automatiskt utifrån dina senast
gillade låtar (YT Music) och mest spelade (Jellyfin), eller direkt
via knappen.
</p>
</div>
<div class="head tracks-head">
<h2>Sångare</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' }}
</button>
</div>
</div>
<div v-if="singers.length" class="grid">
<MusicCard
v-for="(r, i) in singers"
:key="`s-${r.mbid ?? r.name}-${i}`"
:item="r"
@detail="openSinger = $event"
/>
</div>
<div v-else class="empty small">
<p class="dim">
Inga sångarförslag ännu baseras de 10 senaste unika sångarna i
det du gillat (YT Music) och lyssnat (Jellyfin). Klicka in en
sångare för topplåtar och liknande artister, filtrerat mot det du
redan har.
</p>
</div>
<SingerModal v-if="openSinger" :singer="openSinger" @close="openSinger = null" />
</section>
</template>

View File

@@ -5,8 +5,32 @@ const gemini = ref({ apiKey: '', model: '' })
const jellyfin = ref({ url: '', apiKey: '', userId: '' })
const ytmusic = ref({ clientId: '', clientSecret: '' })
const saved = ref({ gemini: {}, jellyfin: {}, ytmusic: {}, suggest: {} })
const intervalHours = ref(24)
const trackIntervalDays = ref(7)
const suggestCfg = ref(null)
const SUGGEST_META = {
mix: {
title: 'Artist & album (Upptäck)',
sources: {
bibliotek: 'Lidarr-biblioteket',
spelat: 'Nyligen spelat (Jellyfin)',
tillagt: 'Nyligen tillagt (YT Music)',
},
},
tracks: {
title: 'Låtförslag (Veckans låtar)',
sources: {
gillade: 'Senast gillade låtar (YT Music)',
mestSpelade: 'Mest spelade (Jellyfin)',
},
},
singers: {
title: 'Sångare',
sources: {
gillade: 'Sångare ur gillade låtar (YT Music)',
spelat: 'Sångare ur spelade låtar (Jellyfin)',
},
},
}
const results = ref({}) // service -> {ok, message}
const busy = ref({})
const jfUsers = ref([])
@@ -18,20 +42,14 @@ onMounted(async () => {
gemini.value.model = saved.value.gemini.model
jellyfin.value.url = saved.value.jellyfin.url
jellyfin.value.userId = saved.value.jellyfin.userId
intervalHours.value = saved.value.suggest?.intervalHours ?? 24
trackIntervalDays.value = saved.value.suggest?.trackIntervalDays ?? 7
suggestCfg.value = saved.value.suggest
})
async function saveInterval() {
async function saveSuggest() {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
suggest: {
intervalHours: Number(intervalHours.value),
trackIntervalDays: Number(trackIntervalDays.value),
},
}),
body: JSON.stringify({ suggest: suggestCfg.value }),
})
results.value = { ...results.value, suggest: { ok: true, message: 'sparat ✓' } }
}
@@ -199,21 +217,50 @@ async function connectYtMusic() {
</article>
<article class="card">
<h3>Förslags-schema</h3>
<h3>Förslag</h3>
<p class="dim">
Hur ofta nya Gemini-förslag hämtas automatiskt. Sätt 0 för att stänga
av du kan alltid klicka "Generera nya förslag" Upptäck-sidan.
Schema, antal och signalkällor per förslagstyp. Intervall 0 = av
du kan alltid generera manuellt från Upptäck-sidan.
</p>
<label>Artist/album-förslag intervall (timmar)
<input v-model="intervalHours" type="number" min="0" step="1" />
</label>
<label>Låtförslag ("Veckans låtar") intervall (dagar)
<input v-model="trackIntervalDays" type="number" min="0" step="1" />
</label>
<div class="row">
<button @click="saveInterval">Spara</button>
<span v-if="results.suggest" :class="results.suggest.ok ? 'ok' : 'fail'">{{ results.suggest.message }}</span>
</div>
<template v-if="suggestCfg">
<details v-for="(meta, type) in SUGGEST_META" :key="type" class="sub">
<summary>{{ meta.title }}
<span class="dim">
var {{ suggestCfg[type].every }}:e
{{ { hours: 'timme', days: 'dag', months: 'månad' }[suggestCfg[type].unit] }},
{{ suggestCfg[type].count }} förslag
</span>
</summary>
<div class="subbody">
<div class="pair">
<label>Intervall
<input v-model="suggestCfg[type].every" type="number" min="0" step="1" />
</label>
<label>Enhet
<select v-model="suggestCfg[type].unit">
<option value="hours">timmar</option>
<option value="days">dagar</option>
<option value="months">månader</option>
</select>
</label>
<label>Antal förslag
<input v-model="suggestCfg[type].count" type="number" min="1" max="25" step="1" />
</label>
</div>
<fieldset>
<legend class="dim">Baseras </legend>
<label v-for="(label, key) in meta.sources" :key="key" class="check">
<input v-model="suggestCfg[type].sources[key]" type="checkbox" />
{{ label }}
</label>
</fieldset>
</div>
</details>
<div class="row">
<button @click="saveSuggest">Spara förslags-inställningar</button>
<span v-if="results.suggest" :class="results.suggest.ok ? 'ok' : 'fail'">{{ results.suggest.message }}</span>
</div>
</template>
</article>
<article class="card">
@@ -360,4 +407,57 @@ select {
color: var(--text-dim);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.sub {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.6rem;
padding: 0.55rem 0.8rem;
}
.sub summary {
cursor: pointer;
font-weight: 600;
}
.subbody {
display: flex;
flex-direction: column;
gap: 0.7rem;
padding-top: 0.7rem;
}
.pair {
display: flex;
gap: 0.7rem;
flex-wrap: wrap;
}
.pair label {
flex: 1;
min-width: 7rem;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
legend {
padding: 0 0 0.3rem;
}
.check {
flex-direction: row;
align-items: center;
gap: 0.5rem;
color: var(--text);
}
.check input {
width: auto;
}
</style>

View File

@@ -1,10 +1,12 @@
<script setup>
import { onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue'
import SingerModal from '../components/SingerModal.vue'
const batches = ref([])
const selected = ref(null)
const loading = ref(false)
const openSinger = ref(null)
async function load() {
const res = await fetch('/api/suggest/batches')
@@ -42,7 +44,7 @@ onMounted(load)
@click="pick(b.id)"
>
<strong>#{{ b.id }}</strong> {{ fmt(b.createdAt) }}
<span class="dim">{{ b.type === 'tracks' ? 'låtar' : 'artister/album' }} · {{ b.count }} förslag · {{ b.trigger }}</span>
<span class="dim">{{ { tracks: 'låtar', singers: 'sångare' }[b.type] ?? 'artister/album' }} · {{ b.count }} förslag · {{ b.trigger }}</span>
</button>
</aside>
@@ -50,16 +52,20 @@ onMounted(load)
<template v-if="selected">
<p class="dim">
Batch #{{ selected.id }} ({{ fmt(selected.createdAt) }}) signaler:
{{ selected.signalCounts.bibliotek }} i biblioteket,
{{ selected.signalCounts.spelat }} nyligen spelade,
{{ selected.signalCounts.tillagt }} nyligen tillagda YT Music
{{ Object.entries(selected.signalCounts ?? {}).map(([k, v]) => `${k}: ${v}`).join(', ') }}
</p>
<div class="grid">
<MusicCard v-for="(r, i) in selected.items" :key="`${selected.id}-${r.mbid ?? i}`" :item="r" />
<MusicCard
v-for="(r, i) in selected.items"
:key="`${selected.id}-${r.mbid ?? i}`"
:item="r"
@detail="openSinger = $event"
/>
</div>
</template>
<p v-else-if="loading" class="dim">Laddar</p>
</div>
<SingerModal v-if="openSinger" :singer="openSinger" @close="openSinger = null" />
</section>
</template>