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 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)) {
|
||||||
|
|||||||
@@ -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
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()
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
16
frontend/public/favicon.svg
Normal file
16
frontend/public/favicon.svg
Normal 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 |
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
192
frontend/src/pages/SearchPage.vue
Normal file
192
frontend/src/pages/SearchPage.vue
Normal 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>
|
||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user