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

@@ -9,6 +9,8 @@ import fastifyStatic from '@fastify/static'
import { lidarrClient, LidarrError } from './lidarr.js'
import { mbClient } from './musicbrainz.js'
import { mapLidarrSearch } from './mapping.js'
import { settingsStore } from './settings.js'
import { makeTesters } from './testers.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -24,6 +26,8 @@ function passwordOk(given, expected) {
export function buildApp(config, deps = {}) {
const lidarr = deps.lidarr ?? lidarrClient(config)
const mb = deps.musicbrainz ?? mbClient()
const store = deps.store ?? settingsStore(config.dataDir ?? '/data')
const testers = deps.testers ?? makeTesters({ config, store })
const app = Fastify({ logger: true, trustProxy: true })
app.register(fastifyCookie)
@@ -140,6 +144,43 @@ export function buildApp(config, deps = {}) {
}
})
app.get('/api/settings', async () => {
const s = store.load()
return {
gemini: {
apiKeySet: Boolean(s.gemini?.apiKey || config.gemini.apiKey),
model: s.gemini?.model ?? config.gemini.model,
},
jellyfin: {
url: s.jellyfin?.url ?? config.jellyfin.url,
apiKeySet: Boolean(s.jellyfin?.apiKey || config.jellyfin.apiKey),
userId: s.jellyfin?.userId ?? '',
userName: s.jellyfin?.userName ?? '',
},
}
})
app.put('/api/settings', async (req) => {
const body = req.body ?? {}
// släpp bara igenom kända fält
store.save({
gemini: { apiKey: body.gemini?.apiKey, model: body.gemini?.model },
jellyfin: {
url: body.jellyfin?.url,
apiKey: body.jellyfin?.apiKey,
userId: body.jellyfin?.userId,
userName: body.jellyfin?.userName,
},
})
return { ok: true }
})
app.post('/api/settings/test', async (req, reply) => {
const service = req.body?.service
if (!testers[service]) return reply.code(400).send({ error: 'okänd tjänst' })
return testers[service]()
})
// byggd frontend (finns bara i containern / efter npm run build)
const publicDir = path.resolve(__dirname, '../public')
if (existsSync(publicDir)) {

View File

@@ -20,5 +20,9 @@ export function loadConfig(env = process.env) {
apiKey: env.GEMINI_API_KEY ?? '',
model: env.GEMINI_MODEL ?? 'gemini-2.5-flash',
},
youtubarr: {
url: env.YOUTUBARR_URL ?? 'http://youtubarr',
dbPath: env.YOUTUBARR_DB ?? '/youtubarr-data/db.sqlite3',
},
}
}

51
backend/src/settings.js Normal file
View File

@@ -0,0 +1,51 @@
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
// Inställningar som läggs in via UI:t (nycklar m.m.) sparas som JSON i
// data-katalogen och har företräde framför env-defaults.
export function settingsStore(dataDir) {
const file = path.join(dataDir, 'settings.json')
function load() {
try {
return JSON.parse(readFileSync(file, 'utf8'))
} catch {
return {}
}
}
// patch-semantik per fält: undefined/null = behåll, '' = rensa, värde = sätt
function save(patch) {
const cur = load()
const next = { ...cur }
for (const [section, fields] of Object.entries(patch ?? {})) {
next[section] = { ...cur[section] }
for (const [k, v] of Object.entries(fields ?? {})) {
if (v === undefined || v === null) continue
if (v === '') delete next[section][k]
else next[section][k] = v
}
}
mkdirSync(dataDir, { recursive: true })
writeFileSync(file, JSON.stringify(next, null, 2))
return next
}
return { load, save, file }
}
// Effektiv konfig = sparade inställningar med env som fallback.
export function effectiveConfig(config, settings) {
return {
gemini: {
apiKey: settings.gemini?.apiKey || config.gemini.apiKey,
model: settings.gemini?.model || config.gemini.model,
},
jellyfin: {
url: (settings.jellyfin?.url || config.jellyfin.url).replace(/\/$/, ''),
apiKey: settings.jellyfin?.apiKey || config.jellyfin.apiKey,
userId: settings.jellyfin?.userId ?? '',
userName: settings.jellyfin?.userName ?? '',
},
}
}

76
backend/src/testers.js Normal file
View File

@@ -0,0 +1,76 @@
import { existsSync } from 'node:fs'
import { effectiveConfig } from './settings.js'
// Live-tester för varje integration — används av "Spara & testa" på
// inställningssidan. Returnerar alltid {ok, message} (+ users för Jellyfin).
export function makeTesters({ config, store, fetchImpl = fetch }) {
const eff = () => effectiveConfig(config, store.load())
return {
async gemini() {
const { apiKey, model } = eff().gemini
if (!apiKey) return { ok: false, message: 'ingen API-nyckel sparad än' }
let res
try {
res = await fetchImpl(
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}?key=${encodeURIComponent(apiKey)}`,
)
} catch {
return { ok: false, message: 'kunde inte nå Gemini API:t (nätfel)' }
}
if (res.status === 404) return { ok: false, message: `nyckeln funkar inte eller modellen "${model}" finns inte` }
if (!res.ok) return { ok: false, message: `Gemini svarade ${res.status} — kontrollera nyckeln` }
return { ok: true, message: `ok — modellen ${model} svarar` }
},
async jellyfin() {
const { url, apiKey } = eff().jellyfin
if (!apiKey) return { ok: false, message: 'ingen API-nyckel sparad än' }
const headers = { 'X-Emby-Token': apiKey }
let info
try {
info = await fetchImpl(`${url}/System/Info`, { headers })
} catch {
return { ok: false, message: `kunde inte nå Jellyfin på ${url}` }
}
if (!info.ok) return { ok: false, message: `Jellyfin svarade ${info.status} — kontrollera URL och nyckel` }
const sys = await info.json()
const usersRes = await fetchImpl(`${url}/Users`, { headers })
const users = usersRes.ok
? (await usersRes.json()).map((u) => ({ id: u.Id, name: u.Name }))
: []
return { ok: true, message: `ok — ${sys.ServerName} (v${sys.Version})`, users }
},
async lidarr() {
let res
try {
res = await fetchImpl(`${config.lidarr.url.replace(/\/$/, '')}/api/v1/system/status`, {
headers: { 'X-Api-Key': config.lidarr.apiKey },
})
} catch {
return { ok: false, message: 'kunde inte nå Lidarr' }
}
if (!res.ok) return { ok: false, message: `Lidarr svarade ${res.status}` }
const js = await res.json()
return { ok: true, message: `ok — Lidarr ${js.version}` }
},
async youtubarr() {
let res
try {
res = await fetchImpl(`${config.youtubarr.url.replace(/\/$/, '')}/healthz`)
} catch {
return { ok: false, message: 'kunde inte nå Youtubarr' }
}
if (!res.ok) return { ok: false, message: `Youtubarr svarade ${res.status}` }
const dbOk = existsSync(config.youtubarr.dbPath)
return {
ok: true,
message: dbOk
? 'ok — healthz svarar och databasen är monterad'
: `ok — healthz svarar, men databasen saknas på ${config.youtubarr.dbPath}`,
}
},
}
}

View File

@@ -0,0 +1,111 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { buildApp } from '../src/app.js'
import { settingsStore, effectiveConfig } from '../src/settings.js'
import { makeTesters } from '../src/testers.js'
function tmpDataDir() {
return mkdtempSync(path.join(tmpdir(), 'lyssnarr-test-'))
}
const baseConfig = {
appPassword: 'testlösen',
sessionSecret: 'x'.repeat(32),
dataDir: '/tmp',
lidarr: { url: 'http://stub', apiKey: 'k', qualityProfileId: 3, metadataProfileId: 1, rootFolder: '/music' },
jellyfin: { url: 'http://jellyfin:8096', apiKey: '' },
gemini: { apiKey: '', model: 'gemini-2.5-flash' },
youtubarr: { url: 'http://youtubarr', dbPath: '/tmp/finns-inte.sqlite3' },
}
async function loggedIn(app) {
const login = await app.inject({ method: 'POST', url: '/api/login', payload: { password: 'testlösen' } })
return login.headers['set-cookie']
}
test('settingsStore sparar, rensar och behåller fält', () => {
const store = settingsStore(tmpDataDir())
store.save({ gemini: { apiKey: 'hemlig', model: 'gemini-2.5-flash' } })
assert.equal(store.load().gemini.apiKey, 'hemlig')
store.save({ gemini: { apiKey: undefined, model: 'gemini-2.5-pro' } })
assert.equal(store.load().gemini.apiKey, 'hemlig', 'undefined ska behålla')
assert.equal(store.load().gemini.model, 'gemini-2.5-pro')
store.save({ gemini: { apiKey: '' } })
assert.equal(store.load().gemini.apiKey, undefined, 'tom sträng ska rensa')
})
test('effectiveConfig: sparat har företräde, env är fallback', () => {
const eff = effectiveConfig(baseConfig, { jellyfin: { apiKey: 'ui-nyckel' } })
assert.equal(eff.jellyfin.apiKey, 'ui-nyckel')
assert.equal(eff.jellyfin.url, 'http://jellyfin:8096')
assert.equal(eff.gemini.model, 'gemini-2.5-flash')
})
test('PUT + GET /api/settings maskerar hemligheter', async () => {
const store = settingsStore(tmpDataDir())
const app = buildApp(baseConfig, { lidarr: {}, musicbrainz: {}, store })
const cookie = await loggedIn(app)
const put = await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie },
payload: { gemini: { apiKey: 'super-hemlig' }, jellyfin: { userId: 'u1', userName: 'brasse' } },
})
assert.equal(put.statusCode, 200)
const get = await app.inject({ method: 'GET', url: '/api/settings', headers: { cookie } })
const body = get.json()
assert.equal(body.gemini.apiKeySet, true)
assert.ok(!JSON.stringify(body).includes('super-hemlig'), 'nyckeln får inte läcka ut')
assert.equal(body.jellyfin.userName, 'brasse')
await app.close()
})
test('jellyfin-testern returnerar användarlista', async () => {
const store = settingsStore(tmpDataDir())
store.save({ jellyfin: { apiKey: 'jf-nyckel' } })
const fetchImpl = async (url) => {
if (url.endsWith('/System/Info')) {
return { ok: true, json: async () => ({ ServerName: 'brasse-pi5', Version: '10.10.0' }) }
}
if (url.endsWith('/Users')) {
return { ok: true, json: async () => [{ Id: 'u1', Name: 'brasse' }] }
}
throw new Error('oväntad url ' + url)
}
const testers = makeTesters({ config: baseConfig, store, fetchImpl })
const out = await testers.jellyfin()
assert.equal(out.ok, true)
assert.match(out.message, /brasse-pi5/)
assert.deepEqual(out.users, [{ id: 'u1', name: 'brasse' }])
})
test('gemini-testern skiljer på saknad nyckel och fel modell', async () => {
const store = settingsStore(tmpDataDir())
const testers = makeTesters({ config: baseConfig, store, fetchImpl: async () => ({ ok: true }) })
assert.equal((await testers.gemini()).ok, false)
store.save({ gemini: { apiKey: 'nyckel' } })
const testers404 = makeTesters({ config: baseConfig, store, fetchImpl: async () => ({ ok: false, status: 404 }) })
const out = await testers404.gemini()
assert.equal(out.ok, false)
assert.match(out.message, /modellen/)
const testersOk = makeTesters({ config: baseConfig, store, fetchImpl: async () => ({ ok: true, status: 200 }) })
assert.equal((await testersOk.gemini()).ok, true)
})
test('POST /api/settings/test avvisar okänd tjänst', async () => {
const app = buildApp(baseConfig, { lidarr: {}, musicbrainz: {}, store: settingsStore(tmpDataDir()) })
const cookie = await loggedIn(app)
const res = await app.inject({
method: 'POST',
url: '/api/settings/test',
headers: { cookie },
payload: { service: 'skum-tjänst' },
})
assert.equal(res.statusCode, 400)
await app.close()
})

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 },
],
})