Inställningssida med Spara & testa för Gemini/Jellyfin + statustester
All checks were successful
build-and-push / build (push) Successful in 12s
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:
@@ -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)) {
|
||||
|
||||
@@ -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
51
backend/src/settings.js
Normal 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
76
backend/src/testers.js
Normal 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}`,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user