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}`,
}
},
}
}