import { existsSync } from 'node:fs' import { timingSafeEqual } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' import Fastify from 'fastify' import fastifyCookie from '@fastify/cookie' import fastifySession from '@fastify/session' 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' import { ytmusicFlow } from './ytmusic.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const OPEN_ROUTES = new Set(['/api/health', '/api/login', '/api/session']) function passwordOk(given, expected) { if (typeof given !== 'string' || !expected) return false const a = Buffer.from(given) const b = Buffer.from(expected) return a.length === b.length && timingSafeEqual(a, b) } 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 ytmusic = deps.ytmusic ?? ytmusicFlow({ config, store }) const app = Fastify({ logger: true, trustProxy: true }) app.register(fastifyCookie) app.register(fastifySession, { secret: config.sessionSecret, cookie: { secure: 'auto', httpOnly: true, sameSite: 'lax', maxAge: 30 * 24 * 60 * 60 * 1000, }, }) // allt under /api utom OPEN_ROUTES kräver inloggad session app.addHook('preHandler', async (req, reply) => { if (!req.url.startsWith('/api/')) return const route = req.url.split('?')[0] if (OPEN_ROUTES.has(route)) return if (req.session.get('user')) return return reply.code(401).send({ error: 'ej inloggad' }) }) app.get('/api/health', async () => ({ status: 'ok' })) app.get('/api/session', async (req) => ({ authenticated: Boolean(req.session.get('user')), })) app.post('/api/login', async (req, reply) => { if (!config.appPassword) { return reply.code(500).send({ error: 'APP_PASSWORD är inte satt' }) } const { password } = req.body ?? {} if (!passwordOk(password, config.appPassword)) { return reply.code(401).send({ error: 'fel lösenord' }) } req.session.set('user', 'brasse') return { ok: true } }) app.post('/api/logout', async (req) => { await req.session.destroy() return { ok: true } }) app.get('/api/search', async (req, reply) => { const q = (req.query.q ?? '').trim() const type = req.query.type ?? 'artist' if (!q) return [] try { if (type === 'track') return await mb.searchTracks(q) return mapLidarrSearch(await lidarr.search(q), type) } catch (err) { req.log.warn({ err }, 'sökningen misslyckades') const source = type === 'track' ? 'MusicBrainz' : 'Lidarr' return reply.code(503).send({ error: `${source} svarar inte just nu — prova igen om en stund` }) } }) // "Hämta": lägg in artist/album i Lidarr, bevakad + sök direkt. // Låtar mappas till sitt album (eller artisten om albumet saknas). app.post('/api/add', async (req, reply) => { const { kind, mbid, artistMbid, albumMbid } = req.body ?? {} async function lookup(wantedKind, id) { const results = await lidarr.search(`lidarr:${id}`) return (results ?? []).find((r) => wantedKind === 'artist' ? r.artist?.foreignArtistId === id : r.album?.foreignAlbumId === id, ) ?? (results ?? [])[0] } try { let addKind = kind let addId = mbid if (kind === 'track') { addKind = albumMbid ? 'album' : 'artist' addId = albumMbid ?? artistMbid if (!addId) return reply.code(400).send({ error: 'låten saknar kopplat album/artist-id' }) } const hit = await lookup(addKind, addId) if (!hit) return reply.code(404).send({ error: 'hittades inte i Lidarrs metadata' }) const profile = { qualityProfileId: config.lidarr.qualityProfileId, metadataProfileId: config.lidarr.metadataProfileId, rootFolderPath: config.lidarr.rootFolder, } if (addKind === 'artist') { if (hit.artist.id) return { ok: true, already: true } await lidarr.addArtist({ ...hit.artist, ...profile, monitored: true, addOptions: { monitor: 'all', searchForMissingAlbums: true }, }) } else { if (hit.album.id) return { ok: true, already: true } await lidarr.addAlbum({ ...hit.album, monitored: true, artist: { ...hit.album.artist, ...profile, monitored: false }, addOptions: { searchForNewAlbum: true }, }) } return { ok: true, already: false } } catch (err) { if (err instanceof LidarrError && err.status === 400 && /already/i.test(err.message)) { return { ok: true, already: true } } req.log.error({ err }, 'kunde inte lägga till i Lidarr') return reply.code(502).send({ error: 'kunde inte lägga till i Lidarr — se loggen' }) } }) 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 ?? '', }, ytmusic: { clientIdSet: Boolean(s.ytmusic?.clientId), clientSecretSet: Boolean(s.ytmusic?.clientSecret), connected: existsSync(ytmusic.oauthPath), }, } }) 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, }, ytmusic: { clientId: body.ytmusic?.clientId, clientSecret: body.ytmusic?.clientSecret, }, }) return { ok: true } }) app.post('/api/ytmusic/start', async (req, reply) => { try { const out = await ytmusic.start() if (out.error) return reply.code(400).send(out) return out } catch (err) { req.log.error({ err }, 'device-flödet kunde inte startas') return reply.code(502).send({ error: 'kunde inte nå Google' }) } }) app.post('/api/ytmusic/poll', async (req, reply) => { const deviceCode = req.body?.deviceCode if (!deviceCode) return reply.code(400).send({ error: 'deviceCode saknas' }) try { return await ytmusic.poll(deviceCode) } catch (err) { req.log.error({ err }, 'device-poll misslyckades') return reply.code(502).send({ error: 'kunde inte nå Google' }) } }) 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)) { app.register(fastifyStatic, { root: publicDir }) app.setNotFoundHandler((req, reply) => { if (req.url.startsWith('/api/')) { return reply.code(404).send({ error: 'finns inte' }) } return reply.sendFile('index.html') }) } return app }