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 fastifyCookie from '@fastify/cookie'
import fastifySession from '@fastify/session' import fastifySession from '@fastify/session'
import fastifyStatic from '@fastify/static' 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)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -18,7 +21,9 @@ function passwordOk(given, expected) {
return a.length === b.length && timingSafeEqual(a, b) 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 }) const app = Fastify({ logger: true, trustProxy: true })
app.register(fastifyCookie) app.register(fastifyCookie)
@@ -64,6 +69,77 @@ export function buildApp(config) {
return { ok: true } 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) // byggd frontend (finns bara i containern / efter npm run build)
const publicDir = path.resolve(__dirname, '../public') const publicDir = path.resolve(__dirname, '../public')
if (existsSync(publicDir)) { if (existsSync(publicDir)) {

View File

@@ -8,6 +8,9 @@ export function loadConfig(env = process.env) {
lidarr: { lidarr: {
url: env.LIDARR_URL ?? 'http://lidarr:8686', url: env.LIDARR_URL ?? 'http://lidarr:8686',
apiKey: env.LIDARR_API_KEY ?? '', 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: { jellyfin: {
url: env.JELLYFIN_URL ?? 'http://jellyfin:8096', 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()
})

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>lyssnarr</title> <title>lyssnarr</title>
</head> </head>
<body> <body>

View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#6366f1"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#g)"/>
<g fill="#ffffff">
<polygon points="26,19 46,14 46,21 30,25"/>
<rect x="26" y="19" width="4" height="27" rx="1"/>
<rect x="42" y="14" width="4" height="27" rx="1"/>
<circle cx="24" cy="46" r="6.5"/>
<circle cx="40" cy="41" r="6.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 587 B

View File

@@ -1,9 +1,19 @@
<script setup> <script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const q = ref(route.query.q ?? '')
const scope = ref(route.query.type ?? 'artist')
function doSearch() {
const term = q.value.trim()
if (!term) return
router.push({ name: 'search', query: { q: term, type: scope.value } })
}
async function logout() { async function logout() {
await fetch('/api/logout', { method: 'POST' }) await fetch('/api/logout', { method: 'POST' })
router.push({ name: 'login' }) router.push({ name: 'login' })
@@ -12,16 +22,17 @@ async function logout() {
<template> <template>
<header v-if="route.name !== 'login'" class="topbar"> <header v-if="route.name !== 'login'" class="topbar">
<div class="brand"> <router-link class="brand" :to="{ name: 'discover' }">
<span class="note"></span> lyssnarr <img src="/favicon.svg" alt="" class="logo" /> lyssnarr
</div> </router-link>
<div class="search"> <form class="search" @submit.prevent="doSearch">
<input <select v-model="scope" aria-label="Söktyp">
type="search" <option value="artist">Artister</option>
placeholder="Sök artister, album, låtar… (kommer i milstolpe 3)" <option value="album">Album</option>
disabled <option value="track">Låtar</option>
/> </select>
</div> <input v-model="q" type="search" placeholder="Sök musik att hämta…" />
</form>
<button class="ghost" @click="logout">Logga ut</button> <button class="ghost" @click="logout">Logga ut</button>
</header> </header>
<main> <main>
@@ -44,19 +55,35 @@ async function logout() {
} }
.brand { .brand {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
white-space: nowrap; white-space: nowrap;
color: var(--text);
} }
.note { .logo {
color: var(--accent-hover); width: 1.7rem;
height: 1.7rem;
} }
.search { .search {
flex: 1; flex: 1;
max-width: 34rem; max-width: 38rem;
display: flex;
gap: 0.5rem;
}
.search select {
font: inherit;
color: var(--text);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem;
padding: 0.45rem 0.6rem;
} }
.ghost { .ghost {

View File

@@ -31,7 +31,7 @@ async function login() {
<template> <template>
<div class="wrap"> <div class="wrap">
<form class="card" @submit.prevent="login"> <form class="card" @submit.prevent="login">
<h1><span class="note"></span> lyssnarr</h1> <h1><img src="/favicon.svg" alt="" class="logo" /> lyssnarr</h1>
<p class="dim">Logga in för att upptäcka ny musik</p> <p class="dim">Logga in för att upptäcka ny musik</p>
<input <input
v-model="password" v-model="password"
@@ -66,11 +66,15 @@ async function login() {
h1 { h1 {
margin: 0; margin: 0;
text-align: center; display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
} }
.note { .logo {
color: var(--accent-hover); width: 2.2rem;
height: 2.2rem;
} }
.dim { .dim {

View File

@@ -0,0 +1,192 @@
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const results = ref([])
const loading = ref(false)
const error = ref('')
const added = ref({}) // mbid -> 'busy' | 'done' | 'fanns'
async function search() {
const { q, type } = route.query
if (!q) return
loading.value = true
error.value = ''
results.value = []
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type ?? 'artist')}`)
const body = await res.json()
if (!res.ok) {
error.value = body.error ?? 'sökningen misslyckades'
return
}
results.value = body
} catch {
error.value = 'sökningen misslyckades'
} finally {
loading.value = false
}
}
async function grab(item) {
added.value = { ...added.value, [item.mbid]: 'busy' }
try {
const res = await fetch('/api/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: item.kind,
mbid: item.mbid,
artistMbid: item.artistMbid,
albumMbid: item.albumMbid,
}),
})
const body = await res.json()
if (!res.ok) {
error.value = body.error ?? 'kunde inte hämta'
added.value = { ...added.value, [item.mbid]: undefined }
return
}
added.value = { ...added.value, [item.mbid]: body.already ? 'fanns' : 'done' }
} catch {
error.value = 'kunde inte hämta'
added.value = { ...added.value, [item.mbid]: undefined }
}
}
const typeLabel = { artist: 'artister', album: 'album', track: 'låtar' }
watch(() => route.query, search, { immediate: true })
</script>
<template>
<section>
<h2>
Sökresultat <span class="dim"> {{ typeLabel[route.query.type] ?? '' }} som matchar "{{ route.query.q }}"</span>
</h2>
<p v-if="loading" class="dim">Söker</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="!loading && !error && results.length === 0" class="dim">Inga träffar.</p>
<div class="grid">
<article v-for="r in results" :key="r.mbid" class="card">
<div class="poster">
<img v-if="r.poster" :src="r.poster" alt="" loading="lazy" @error="r.poster = null" />
<span v-else class="note"></span>
<span class="kind">{{ { artist: 'Artist', album: 'Album', track: 'Låt' }[r.kind] }}</span>
</div>
<div class="meta">
<strong :title="r.name">{{ r.name }}</strong>
<span class="dim" :title="r.secondary">{{ r.secondary }} <template v-if="r.year">· {{ r.year }}</template></span>
</div>
<button
v-if="r.inLibrary || added[r.mbid] === 'fanns'"
class="have"
disabled
>
I biblioteket
</button>
<button
v-else-if="added[r.mbid] === 'done'"
class="have"
disabled
>
Hämtas
</button>
<button v-else :disabled="added[r.mbid] === 'busy'" @click="grab(r)">
{{ added[r.mbid] === 'busy' ? 'Lägger till' : ' Hämta' }}
</button>
</article>
</div>
</section>
</template>
<style scoped>
h2 {
margin: 0 0 1rem;
}
.dim {
color: var(--text-dim);
font-weight: 400;
}
.error {
color: var(--danger);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 1rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.6rem;
background: var(--card);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.8rem;
padding: 0.7rem;
transition: background 0.15s;
}
.card:hover {
background: var(--card-hover);
}
.poster {
position: relative;
aspect-ratio: 1;
border-radius: 0.5rem;
overflow: hidden;
background: linear-gradient(160deg, #312e81, #1e1b4b);
display: grid;
place-items: center;
}
.poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.note {
font-size: 2.5rem;
color: var(--accent-hover);
}
.kind {
position: absolute;
top: 0.4rem;
left: 0.4rem;
font-size: 0.7rem;
padding: 0.1rem 0.45rem;
border-radius: 0.4rem;
background: rgba(17, 24, 39, 0.8);
color: var(--text-dim);
}
.meta {
display: flex;
flex-direction: column;
min-height: 2.6rem;
}
.meta strong,
.meta .dim {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.have {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--text-dim);
cursor: default;
}
</style>

View File

@@ -1,11 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import DiscoverPage from './pages/DiscoverPage.vue' import DiscoverPage from './pages/DiscoverPage.vue'
import LoginPage from './pages/LoginPage.vue' import LoginPage from './pages/LoginPage.vue'
import SearchPage from './pages/SearchPage.vue'
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', name: 'discover', component: DiscoverPage }, { path: '/', name: 'discover', component: DiscoverPage },
{ path: '/search', name: 'search', component: SearchPage },
{ path: '/login', name: 'login', component: LoginPage }, { path: '/login', name: 'login', component: LoginPage },
], ],
}) })