diff --git a/backend/src/app.js b/backend/src/app.js index 57563d0..1b48eee 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -6,6 +6,9 @@ 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' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -18,7 +21,9 @@ function passwordOk(given, expected) { return a.length === b.length && timingSafeEqual(a, b) } -export function buildApp(config) { +export function buildApp(config, deps = {}) { + const lidarr = deps.lidarr ?? lidarrClient(config) + const mb = deps.musicbrainz ?? mbClient() const app = Fastify({ logger: true, trustProxy: true }) app.register(fastifyCookie) @@ -64,6 +69,77 @@ export function buildApp(config) { 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' }) + } + }) + // byggd frontend (finns bara i containern / efter npm run build) const publicDir = path.resolve(__dirname, '../public') if (existsSync(publicDir)) { diff --git a/backend/src/config.js b/backend/src/config.js index b3a066f..96df8ab 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -8,6 +8,9 @@ export function loadConfig(env = process.env) { lidarr: { url: env.LIDARR_URL ?? 'http://lidarr:8686', apiKey: env.LIDARR_API_KEY ?? '', + qualityProfileId: Number(env.LIDARR_QUALITY_PROFILE_ID ?? 3), + metadataProfileId: Number(env.LIDARR_METADATA_PROFILE_ID ?? 1), + rootFolder: env.LIDARR_ROOT_FOLDER ?? '/music', }, jellyfin: { url: env.JELLYFIN_URL ?? 'http://jellyfin:8096', diff --git a/backend/src/lidarr.js b/backend/src/lidarr.js new file mode 100644 index 0000000..0edefce --- /dev/null +++ b/backend/src/lidarr.js @@ -0,0 +1,32 @@ +export class LidarrError extends Error { + constructor(status, message) { + super(message) + this.status = status + } +} + +export function lidarrClient(config) { + const base = config.lidarr.url.replace(/\/$/, '') + + async function req(method, path, body) { + const res = await fetch(base + path, { + method, + headers: { + 'X-Api-Key': config.lidarr.apiKey, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new LidarrError(res.status, `Lidarr ${method} ${path} -> ${res.status} ${text.slice(0, 300)}`) + } + return res.status === 204 ? null : res.json() + } + + return { + search: (term) => req('GET', `/api/v1/search?term=${encodeURIComponent(term)}`), + addArtist: (payload) => req('POST', '/api/v1/artist', payload), + addAlbum: (payload) => req('POST', '/api/v1/album', payload), + } +} diff --git a/backend/src/mapping.js b/backend/src/mapping.js new file mode 100644 index 0000000..1b59e39 --- /dev/null +++ b/backend/src/mapping.js @@ -0,0 +1,37 @@ +function pickImage(images) { + if (!Array.isArray(images) || images.length === 0) return null + const p = images.find((i) => i.coverType === 'poster' || i.coverType === 'cover') ?? images[0] + return p?.remoteUrl ?? p?.url ?? null +} + +// Lidarrs /api/v1/search blandar artist- och album-träffar; plocka ut +// den typ användaren bad om och forma om till sökkort. +export function mapLidarrSearch(results, type) { + const out = [] + for (const r of results ?? []) { + if (type === 'artist' && r.artist) { + const a = r.artist + out.push({ + kind: 'artist', + name: a.artistName, + secondary: a.disambiguation ?? '', + year: '', + mbid: a.foreignArtistId, + poster: pickImage(a.images), + inLibrary: Boolean(a.id), + }) + } else if (type === 'album' && r.album) { + const al = r.album + out.push({ + kind: 'album', + name: al.title, + secondary: al.artist?.artistName ?? '', + year: (al.releaseDate ?? '').slice(0, 4), + mbid: al.foreignAlbumId, + poster: pickImage(al.images), + inLibrary: Boolean(al.id), + }) + } + } + return out +} diff --git a/backend/src/musicbrainz.js b/backend/src/musicbrainz.js new file mode 100644 index 0000000..8d19478 --- /dev/null +++ b/backend/src/musicbrainz.js @@ -0,0 +1,26 @@ +const USER_AGENT = 'lyssnarr/0.1 (brasse@brasse-pc.eu)' + +export function mbClient() { + return { + // Låtsök går direkt mot MusicBrainz (Lidarrs metadata-API söker bara + // artister/album). Kastar vid nätfel — routen översätter till 503. + async searchTracks(q) { + const query = encodeURIComponent(`recording:"${q}"`) + const url = `https://musicbrainz.org/ws/2/recording?query=${query}&limit=24&fmt=json` + const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } }) + if (!res.ok) throw new Error(`MusicBrainz svarade ${res.status}`) + const js = await res.json() + return (js.recordings ?? []).map((r) => ({ + kind: 'track', + name: r.title, + secondary: r['artist-credit']?.[0]?.name ?? '', + year: (r['first-release-date'] ?? '').slice(0, 4), + mbid: r.id, + artistMbid: r['artist-credit']?.[0]?.artist?.id ?? null, + albumMbid: r.releases?.[0]?.['release-group']?.id ?? null, + poster: null, + inLibrary: false, + })) + }, + } +} diff --git a/backend/test/search.test.js b/backend/test/search.test.js new file mode 100644 index 0000000..9d8b846 --- /dev/null +++ b/backend/test/search.test.js @@ -0,0 +1,147 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildApp } from '../src/app.js' +import { mapLidarrSearch } from '../src/mapping.js' + +const config = { + appPassword: 'testlösen', + sessionSecret: 'x'.repeat(32), + lidarr: { url: 'http://stub', apiKey: 'k', qualityProfileId: 3, metadataProfileId: 1, rootFolder: '/music' }, + jellyfin: { url: '', apiKey: '' }, + gemini: { apiKey: '', model: '' }, +} + +const powerwolf = { + foreignArtistId: 'mbid-artist-1', + artistName: 'Powerwolf', + disambiguation: 'tysk powermetal', + images: [{ coverType: 'poster', remoteUrl: 'http://img/poster.jpg' }], +} + +const album = { + foreignAlbumId: 'mbid-album-1', + title: 'The Sacrament of Sin', + releaseDate: '2018-07-20', + images: [], + artist: { ...powerwolf }, +} + +async function loggedIn(app) { + const login = await app.inject({ method: 'POST', url: '/api/login', payload: { password: 'testlösen' } }) + return login.headers['set-cookie'] +} + +test('mapLidarrSearch formar artist- och albumkort', () => { + const results = [{ artist: powerwolf }, { album }] + const artists = mapLidarrSearch(results, 'artist') + assert.equal(artists.length, 1) + assert.deepEqual(artists[0], { + kind: 'artist', + name: 'Powerwolf', + secondary: 'tysk powermetal', + year: '', + mbid: 'mbid-artist-1', + poster: 'http://img/poster.jpg', + inLibrary: false, + }) + const albums = mapLidarrSearch(results, 'album') + assert.equal(albums[0].year, '2018') + assert.equal(albums[0].secondary, 'Powerwolf') +}) + +test('GET /api/search kräver inloggning', async () => { + const app = buildApp(config, { lidarr: {}, musicbrainz: {} }) + const res = await app.inject({ method: 'GET', url: '/api/search?q=powerwolf' }) + assert.equal(res.statusCode, 401) + await app.close() +}) + +test('GET /api/search returnerar mappade träffar', async () => { + const app = buildApp(config, { + lidarr: { search: async () => [{ artist: powerwolf }] }, + musicbrainz: {}, + }) + const cookie = await loggedIn(app) + const res = await app.inject({ method: 'GET', url: '/api/search?q=powerwolf&type=artist', headers: { cookie } }) + assert.equal(res.statusCode, 200) + assert.equal(res.json()[0].name, 'Powerwolf') + await app.close() +}) + +test('GET /api/search ger 503 när källan är nere', async () => { + const app = buildApp(config, { + lidarr: { search: async () => { throw new Error('nere') } }, + musicbrainz: {}, + }) + const cookie = await loggedIn(app) + const res = await app.inject({ method: 'GET', url: '/api/search?q=x&type=artist', headers: { cookie } }) + assert.equal(res.statusCode, 503) + await app.close() +}) + +test('POST /api/add lägger till artist med profiler och bevakning', async () => { + let sent = null + const app = buildApp(config, { + lidarr: { + search: async (term) => { + assert.equal(term, 'lidarr:mbid-artist-1') + return [{ artist: { ...powerwolf } }] + }, + addArtist: async (payload) => { sent = payload; return { id: 7 } }, + }, + musicbrainz: {}, + }) + const cookie = await loggedIn(app) + const res = await app.inject({ + method: 'POST', + url: '/api/add', + headers: { cookie }, + payload: { kind: 'artist', mbid: 'mbid-artist-1' }, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.json(), { ok: true, already: false }) + assert.equal(sent.qualityProfileId, 3) + assert.equal(sent.rootFolderPath, '/music') + assert.equal(sent.monitored, true) + assert.equal(sent.addOptions.searchForMissingAlbums, true) + await app.close() +}) + +test('POST /api/add med låt går via albumet', async () => { + let sent = null + const app = buildApp(config, { + lidarr: { + search: async () => [{ album: { ...album } }], + addAlbum: async (payload) => { sent = payload; return { id: 9 } }, + }, + musicbrainz: {}, + }) + const cookie = await loggedIn(app) + const res = await app.inject({ + method: 'POST', + url: '/api/add', + headers: { cookie }, + payload: { kind: 'track', mbid: 'rec-1', artistMbid: 'mbid-artist-1', albumMbid: 'mbid-album-1' }, + }) + assert.equal(res.statusCode, 200) + assert.equal(sent.monitored, true) + assert.equal(sent.artist.rootFolderPath, '/music') + assert.equal(sent.addOptions.searchForNewAlbum, true) + await app.close() +}) + +test('POST /api/add svarar already för befintlig artist', async () => { + const app = buildApp(config, { + lidarr: { search: async () => [{ artist: { ...powerwolf, id: 12 } }] }, + musicbrainz: {}, + }) + const cookie = await loggedIn(app) + const res = await app.inject({ + method: 'POST', + url: '/api/add', + headers: { cookie }, + payload: { kind: 'artist', mbid: 'mbid-artist-1' }, + }) + assert.deepEqual(res.json(), { ok: true, already: true }) + await app.close() +}) diff --git a/frontend/index.html b/frontend/index.html index 2f679d5..760d9c2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@
+Söker…
+{{ error }}
+Inga träffar.
+ +