Inställningssida med Spara & testa för Gemini/Jellyfin + statustester
All checks were successful
build-and-push / build (push) Successful in 12s

- settings.json i /data (UI-värden har företräde framför env), hemligheter
  maskeras i GET (bara apiKeySet-flagga)
- POST /api/settings/test kör live-test per tjänst: Gemini (nyckel+modell),
  Jellyfin (System/Info + användarlista för dropdown), Lidarr, Youtubarr
  (healthz + db-mount)
- Inställningssida (kugghjulet): Spara & testa i samma steg per sektion,
  Jellyfin-användarväljare fylls från lyckat test
- 6 nya tester, totalt 17

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 01:03:46 +02:00
parent 92d1a7b7ec
commit aea7ec3ca7
8 changed files with 511 additions and 0 deletions

View File

@@ -33,6 +33,7 @@ async function logout() {
</select>
<input v-model="q" type="search" placeholder="Sök musik att hämta…" />
</form>
<router-link class="ghost gear" :to="{ name: 'settings' }" title="Inställningar"></router-link>
<button class="ghost" @click="logout">Logga ut</button>
</header>
<main>
@@ -90,6 +91,14 @@ async function logout() {
background: transparent;
color: var(--text-dim);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.5rem;
padding: 0.55rem 1.1rem;
}
.gear {
padding: 0.45rem 0.7rem;
font-size: 1.1rem;
text-decoration: none;
}
.ghost:hover {

View File

@@ -0,0 +1,217 @@
<script setup>
import { onMounted, ref } from 'vue'
const gemini = ref({ apiKey: '', model: '' })
const jellyfin = ref({ url: '', apiKey: '', userId: '' })
const saved = ref({ gemini: {}, jellyfin: {} })
const results = ref({}) // service -> {ok, message}
const busy = ref({})
const jfUsers = ref([])
onMounted(async () => {
const res = await fetch('/api/settings')
saved.value = await res.json()
gemini.value.model = saved.value.gemini.model
jellyfin.value.url = saved.value.jellyfin.url
jellyfin.value.userId = saved.value.jellyfin.userId
})
async function saveAndTest(service) {
busy.value = { ...busy.value, [service]: true }
results.value = { ...results.value, [service]: null }
try {
const body =
service === 'gemini'
? { gemini: { apiKey: gemini.value.apiKey || null, model: gemini.value.model || null } }
: {
jellyfin: {
url: jellyfin.value.url || null,
apiKey: jellyfin.value.apiKey || null,
userId: jellyfin.value.userId || null,
userName: jfUsers.value.find((u) => u.id === jellyfin.value.userId)?.name ?? null,
},
}
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const res = await fetch('/api/settings/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service }),
})
const out = await res.json()
results.value = { ...results.value, [service]: out }
if (service === 'jellyfin' && out.users) jfUsers.value = out.users
if (out.ok) {
const s = await (await fetch('/api/settings')).json()
saved.value = s
if (service === 'gemini') gemini.value.apiKey = ''
if (service === 'jellyfin') jellyfin.value.apiKey = ''
}
} catch {
results.value = { ...results.value, [service]: { ok: false, message: 'något gick fel' } }
} finally {
busy.value = { ...busy.value, [service]: false }
}
}
async function test(service) {
busy.value = { ...busy.value, [service]: true }
try {
const res = await fetch('/api/settings/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service }),
})
results.value = { ...results.value, [service]: await res.json() }
} finally {
busy.value = { ...busy.value, [service]: false }
}
}
async function pickUser() {
await saveAndTest('jellyfin')
}
</script>
<template>
<section class="settings">
<h2>Inställningar</h2>
<article class="card">
<h3>Gemini <span v-if="saved.gemini.apiKeySet" class="badge">nyckel sparad </span></h3>
<p class="dim">Driver förslagen Upptäck-sidan. Skapa nyckel aistudio.google.com/apikey.</p>
<label>API-nyckel
<input v-model="gemini.apiKey" type="password" :placeholder="saved.gemini.apiKeySet ? '•••••• (sparad — fyll i för att byta)' : 'AIza…'" />
</label>
<label>Modell
<input v-model="gemini.model" type="text" placeholder="gemini-2.5-flash" />
</label>
<div class="row">
<button :disabled="busy.gemini" @click="saveAndTest('gemini')">
{{ busy.gemini ? 'Testar' : 'Spara & testa' }}
</button>
<span v-if="results.gemini" :class="results.gemini.ok ? 'ok' : 'fail'">{{ results.gemini.message }}</span>
</div>
</article>
<article class="card">
<h3>Jellyfin <span v-if="saved.jellyfin.apiKeySet" class="badge">nyckel sparad </span></h3>
<p class="dim">
Läser din lyssningshistorik som förslags-signal. Nyckel skapas i Jellyfin:
Instrumentpanelen API-nycklar.
<template v-if="saved.jellyfin.userName"> Vald användare: <strong>{{ saved.jellyfin.userName }}</strong>.</template>
</p>
<label>URL
<input v-model="jellyfin.url" type="text" placeholder="http://jellyfin:8096" />
</label>
<label>API-nyckel
<input v-model="jellyfin.apiKey" type="password" :placeholder="saved.jellyfin.apiKeySet ? '•••••• (sparad — fyll i för att byta)' : ''" />
</label>
<label v-if="jfUsers.length">Användare (vems historik)
<select v-model="jellyfin.userId" @change="pickUser">
<option disabled value="">välj användare</option>
<option v-for="u in jfUsers" :key="u.id" :value="u.id">{{ u.name }}</option>
</select>
</label>
<div class="row">
<button :disabled="busy.jellyfin" @click="saveAndTest('jellyfin')">
{{ busy.jellyfin ? 'Testar' : 'Spara & testa' }}
</button>
<span v-if="results.jellyfin" :class="results.jellyfin.ok ? 'ok' : 'fail'">{{ results.jellyfin.message }}</span>
</div>
</article>
<article class="card">
<h3>Status fasta kopplingar</h3>
<p class="dim">Lidarr och Youtubarr konfigureras i stackens .env Pi5:an; här kan du testa att de svarar.</p>
<div class="row">
<button :disabled="busy.lidarr" @click="test('lidarr')">{{ busy.lidarr ? 'Testar' : 'Testa Lidarr' }}</button>
<span v-if="results.lidarr" :class="results.lidarr.ok ? 'ok' : 'fail'">{{ results.lidarr.message }}</span>
</div>
<div class="row">
<button :disabled="busy.youtubarr" @click="test('youtubarr')">{{ busy.youtubarr ? 'Testar' : 'Testa Youtubarr' }}</button>
<span v-if="results.youtubarr" :class="results.youtubarr.ok ? 'ok' : 'fail'">{{ results.youtubarr.message }}</span>
</div>
</article>
</section>
</template>
<style scoped>
.settings {
max-width: 42rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
h2 {
margin: 0;
}
.card {
background: var(--card);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.8rem;
padding: 1.1rem 1.3rem;
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.card h3 {
margin: 0;
display: flex;
align-items: center;
gap: 0.6rem;
}
.badge {
font-size: 0.72rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: 0.4rem;
background: rgba(52, 211, 153, 0.15);
color: #34d399;
}
.dim {
margin: 0;
color: var(--text-dim);
font-size: 0.9rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.9rem;
color: var(--text-dim);
}
select {
font: inherit;
color: var(--text);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem;
padding: 0.5rem 0.7rem;
}
.row {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.ok {
color: #34d399;
}
.fail {
color: var(--danger);
}
</style>

View File

@@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
import DiscoverPage from './pages/DiscoverPage.vue'
import LoginPage from './pages/LoginPage.vue'
import SearchPage from './pages/SearchPage.vue'
import SettingsPage from './pages/SettingsPage.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'discover', component: DiscoverPage },
{ path: '/search', name: 'search', component: SearchPage },
{ path: '/settings', name: 'settings', component: SettingsPage },
{ path: '/login', name: 'login', component: LoginPage },
],
})