diff --git a/backend/src/app.js b/backend/src/app.js index 1b48eee..48976f0 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -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)) { diff --git a/backend/src/config.js b/backend/src/config.js index 96df8ab..b9ff290 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -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', + }, } } diff --git a/backend/src/settings.js b/backend/src/settings.js new file mode 100644 index 0000000..aeda164 --- /dev/null +++ b/backend/src/settings.js @@ -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 ?? '', + }, + } +} diff --git a/backend/src/testers.js b/backend/src/testers.js new file mode 100644 index 0000000..4e2fd89 --- /dev/null +++ b/backend/src/testers.js @@ -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}`, + } + }, + } +} diff --git a/backend/test/settings.test.js b/backend/test/settings.test.js new file mode 100644 index 0000000..bfa8f91 --- /dev/null +++ b/backend/test/settings.test.js @@ -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() +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0c4e5a2..ae5f69b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -33,6 +33,7 @@ async function logout() { +
@@ -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 { diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue new file mode 100644 index 0000000..92b694c --- /dev/null +++ b/frontend/src/pages/SettingsPage.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 6de9bbc..de06e45 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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 }, ], })