All checks were successful
build-and-push / build (push) Successful in 16s
- POST /api/ytmusic/start + /poll: kör device-flödet mot Google och skriver ytmusicapi-kompatibel oauth.json till Youtubarrs datamapp (mounten byts till rw i stacken) - Settings-sektion: Client ID/secret, 'Spara & koppla' visar kod + länk och pollar tills godkänt; 'Testa kopplingen' verifierar med en token-förnyelse mot Google - 4 nya tester, totalt 21 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
231 lines
7.7 KiB
JavaScript
231 lines
7.7 KiB
JavaScript
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
|
|
}
|