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
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:
@@ -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)) {
|
||||
|
||||
@@ -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
32
backend/src/lidarr.js
Normal 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
37
backend/src/mapping.js
Normal 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
|
||||
}
|
||||
26
backend/src/musicbrainz.js
Normal file
26
backend/src/musicbrainz.js
Normal 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
147
backend/test/search.test.js
Normal 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()
|
||||
})
|
||||
Reference in New Issue
Block a user