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 { lidarrClient, LidarrError } from './lidarr.js'
|
||||||
import { mbClient } from './musicbrainz.js'
|
import { mbClient } from './musicbrainz.js'
|
||||||
import { mapLidarrSearch } from './mapping.js'
|
import { mapLidarrSearch } from './mapping.js'
|
||||||
|
import { settingsStore } from './settings.js'
|
||||||
|
import { makeTesters } from './testers.js'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ function passwordOk(given, expected) {
|
|||||||
export function buildApp(config, deps = {}) {
|
export function buildApp(config, deps = {}) {
|
||||||
const lidarr = deps.lidarr ?? lidarrClient(config)
|
const lidarr = deps.lidarr ?? lidarrClient(config)
|
||||||
const mb = deps.musicbrainz ?? mbClient()
|
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 })
|
const app = Fastify({ logger: true, trustProxy: true })
|
||||||
|
|
||||||
app.register(fastifyCookie)
|
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)
|
// byggd frontend (finns bara i containern / efter npm run build)
|
||||||
const publicDir = path.resolve(__dirname, '../public')
|
const publicDir = path.resolve(__dirname, '../public')
|
||||||
if (existsSync(publicDir)) {
|
if (existsSync(publicDir)) {
|
||||||
|
|||||||
@@ -20,5 +20,9 @@ export function loadConfig(env = process.env) {
|
|||||||
apiKey: env.GEMINI_API_KEY ?? '',
|
apiKey: env.GEMINI_API_KEY ?? '',
|
||||||
model: env.GEMINI_MODEL ?? 'gemini-2.5-flash',
|
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}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
111
backend/test/settings.test.js
Normal file
111
backend/test/settings.test.js
Normal 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()
|
||||||
|
})
|
||||||
@@ -33,6 +33,7 @@ async function logout() {
|
|||||||
</select>
|
</select>
|
||||||
<input v-model="q" type="search" placeholder="Sök musik att hämta…" />
|
<input v-model="q" type="search" placeholder="Sök musik att hämta…" />
|
||||||
</form>
|
</form>
|
||||||
|
<router-link class="ghost gear" :to="{ name: 'settings' }" title="Inställningar">⚙</router-link>
|
||||||
<button class="ghost" @click="logout">Logga ut</button>
|
<button class="ghost" @click="logout">Logga ut</button>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
@@ -90,6 +91,14 @@ async function logout() {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
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 {
|
.ghost:hover {
|
||||||
|
|||||||
217
frontend/src/pages/SettingsPage.vue
Normal file
217
frontend/src/pages/SettingsPage.vue
Normal 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 på Upptäck-sidan. Skapa nyckel på 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 på 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>
|
||||||
@@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import DiscoverPage from './pages/DiscoverPage.vue'
|
import DiscoverPage from './pages/DiscoverPage.vue'
|
||||||
import LoginPage from './pages/LoginPage.vue'
|
import LoginPage from './pages/LoginPage.vue'
|
||||||
import SearchPage from './pages/SearchPage.vue'
|
import SearchPage from './pages/SearchPage.vue'
|
||||||
|
import SettingsPage from './pages/SettingsPage.vue'
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'discover', component: DiscoverPage },
|
{ path: '/', name: 'discover', component: DiscoverPage },
|
||||||
{ path: '/search', name: 'search', component: SearchPage },
|
{ path: '/search', name: 'search', component: SearchPage },
|
||||||
|
{ path: '/settings', name: 'settings', component: SettingsPage },
|
||||||
{ path: '/login', name: 'login', component: LoginPage },
|
{ path: '/login', name: 'login', component: LoginPage },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user