Milstolpe 2+3: ikon, sök (artist/album/låt) och Hämta-till-Lidarr
All checks were successful
build-and-push / build (push) Successful in 16s

- Ikon: SVG-favicon (indigo-gradient + balknot), används i topbar,
  login och som favicon
- GET /api/search: artister/album via Lidarrs metadata-API (funkar även
  när musicbrainz.org är nere), låtar via MusicBrainz recording-sök;
  503 med svenskt felmeddelande när källan inte svarar
- POST /api/add: slår upp via lidarr:<mbid>, artist läggs bevakad med
  searchForMissingAlbums, album bevakat med searchForNewAlbum (artisten
  följer med obevakad), låtar mappas till album/artist; 'already' när
  det redan finns
- Söksida med Jellyseerr-lik kortgrid: poster/fallback, typ-badge,
  Hämta-knapp med busy/klar/finns-lägen; sökfältet i topbaren aktivt
- 7 nya tester (mappning + rutter med stubbat Lidarr), totalt 11

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
2026-08-01 00:47:37 +02:00
parent 4e0a8aae13
commit 92d1a7b7ec
12 changed files with 581 additions and 18 deletions

View File

@@ -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)) {

View File

@@ -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',

32
backend/src/lidarr.js Normal file
View File

@@ -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),
}
}

37
backend/src/mapping.js Normal file
View File

@@ -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
}

View File

@@ -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,
}))
},
}
}

147
backend/test/search.test.js Normal file
View File

@@ -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()
})