Milstolpe 1: skelett — Fastify-backend med login, Vue-frontend (mörkt Jellyseerr-tema), Dockerfile
All checks were successful
build-and-push / build (push) Successful in 32s

- backend: /api/health, /api/login|logout|session, sessionscookie,
  timingSafeEqual-lösenordskoll, allt annat under /api kräver inloggning,
  serverar byggd frontend med SPA-fallback; 4 tester (node:test)
- frontend: Vue 3 + Vite, login-sida, Upptäck-skal, sökfält (stub),
  mörkt tema enligt Jellyseerr-förlagan
- docker: multi-stage arm64-image; config-plumbing för Lidarr/Jellyfin/
  Gemini via env (Jellyfin-historik prioriterad som signal per Björn)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
2026-07-31 23:18:25 +02:00
parent f164a263ee
commit 4e0a8aae13
20 changed files with 3092 additions and 5 deletions

View File

@@ -21,7 +21,7 @@ sudo pacman -S nodejs npm # node >= 20
```bash
git clone gitea-claude:brasse/lyssnarr.git
cd lyssnarr
npm install # installs backend + frontend workspaces
npm run setup # installs backend + frontend deps
npm run build # -> ./build/ (frontend dist + backend)
npm run dev # dev servers (Vite + Fastify, hot reload)
npm test

1133
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
backend/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "lyssnarr-backend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"test": "node --test"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/session": "^11.1.0",
"@fastify/static": "^8.1.1",
"fastify": "^5.3.2"
}
}

80
backend/src/app.js Normal file
View File

@@ -0,0 +1,80 @@
import { existsSync } from 'node:fs'
import { timingSafeEqual } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import Fastify from 'fastify'
import fastifyCookie from '@fastify/cookie'
import fastifySession from '@fastify/session'
import fastifyStatic from '@fastify/static'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OPEN_ROUTES = new Set(['/api/health', '/api/login', '/api/session'])
function passwordOk(given, expected) {
if (typeof given !== 'string' || !expected) return false
const a = Buffer.from(given)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
export function buildApp(config) {
const app = Fastify({ logger: true, trustProxy: true })
app.register(fastifyCookie)
app.register(fastifySession, {
secret: config.sessionSecret,
cookie: {
secure: 'auto',
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
},
})
// allt under /api utom OPEN_ROUTES kräver inloggad session
app.addHook('preHandler', async (req, reply) => {
if (!req.url.startsWith('/api/')) return
const route = req.url.split('?')[0]
if (OPEN_ROUTES.has(route)) return
if (req.session.get('user')) return
return reply.code(401).send({ error: 'ej inloggad' })
})
app.get('/api/health', async () => ({ status: 'ok' }))
app.get('/api/session', async (req) => ({
authenticated: Boolean(req.session.get('user')),
}))
app.post('/api/login', async (req, reply) => {
if (!config.appPassword) {
return reply.code(500).send({ error: 'APP_PASSWORD är inte satt' })
}
const { password } = req.body ?? {}
if (!passwordOk(password, config.appPassword)) {
return reply.code(401).send({ error: 'fel lösenord' })
}
req.session.set('user', 'brasse')
return { ok: true }
})
app.post('/api/logout', async (req) => {
await req.session.destroy()
return { ok: true }
})
// byggd frontend (finns bara i containern / efter npm run build)
const publicDir = path.resolve(__dirname, '../public')
if (existsSync(publicDir)) {
app.register(fastifyStatic, { root: publicDir })
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api/')) {
return reply.code(404).send({ error: 'finns inte' })
}
return reply.sendFile('index.html')
})
}
return app
}

21
backend/src/config.js Normal file
View File

@@ -0,0 +1,21 @@
export function loadConfig(env = process.env) {
return {
port: Number(env.PORT ?? 3000),
host: env.HOST ?? '0.0.0.0',
appPassword: env.APP_PASSWORD ?? '',
sessionSecret: env.SESSION_SECRET ?? '',
dataDir: env.DATA_DIR ?? '/data',
lidarr: {
url: env.LIDARR_URL ?? 'http://lidarr:8686',
apiKey: env.LIDARR_API_KEY ?? '',
},
jellyfin: {
url: env.JELLYFIN_URL ?? 'http://jellyfin:8096',
apiKey: env.JELLYFIN_API_KEY ?? '',
},
gemini: {
apiKey: env.GEMINI_API_KEY ?? '',
model: env.GEMINI_MODEL ?? 'gemini-2.5-flash',
},
}
}

16
backend/src/server.js Normal file
View File

@@ -0,0 +1,16 @@
import { loadConfig } from './config.js'
import { buildApp } from './app.js'
const config = loadConfig()
if (!config.sessionSecret || config.sessionSecret.length < 32) {
console.error('SESSION_SECRET saknas eller är kortare än 32 tecken')
process.exit(1)
}
const app = buildApp(config)
app.listen({ port: config.port, host: config.host }).catch((err) => {
app.log.error(err)
process.exit(1)
})

57
backend/test/app.test.js Normal file
View File

@@ -0,0 +1,57 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildApp } from '../src/app.js'
const config = {
appPassword: 'testlösen',
sessionSecret: 'x'.repeat(32),
lidarr: { url: '', apiKey: '' },
jellyfin: { url: '', apiKey: '' },
gemini: { apiKey: '', model: '' },
}
test('health svarar utan inloggning', async () => {
const app = buildApp(config)
const res = await app.inject({ method: 'GET', url: '/api/health' })
assert.equal(res.statusCode, 200)
assert.deepEqual(res.json(), { status: 'ok' })
await app.close()
})
test('fel lösenord ger 401', async () => {
const app = buildApp(config)
const res = await app.inject({
method: 'POST',
url: '/api/login',
payload: { password: 'fel' },
})
assert.equal(res.statusCode, 401)
await app.close()
})
test('rätt lösenord ger session-cookie och åtkomst', async () => {
const app = buildApp(config)
const login = await app.inject({
method: 'POST',
url: '/api/login',
payload: { password: 'testlösen' },
})
assert.equal(login.statusCode, 200)
const cookie = login.headers['set-cookie']
assert.ok(cookie, 'ingen session-cookie sattes')
const session = await app.inject({
method: 'GET',
url: '/api/session',
headers: { cookie },
})
assert.equal(session.json().authenticated, true)
await app.close()
})
test('skyddad API-rutt utan session ger 401', async () => {
const app = buildApp(config)
const res = await app.inject({ method: 'GET', url: '/api/whatever' })
assert.equal(res.statusCode, 401)
await app.close()
})

View File

@@ -38,7 +38,7 @@ stack `.env`, signed session cookie).
| **YouTube Music** | unofficial YT Music search from Node → browse/video IDs for "open in YT Music" deep links, thumbnails for the cards, inline playback via YouTube IFrame embed |
| **Gemini API** | suggestion generation; `GEMINI_API_KEY` + model via env |
| **Youtubarr** | read-only mount of its SQLite (`/srv/docker/youtubarr/data/db.sqlite3`) → "recently added to playlists" signal |
| **Jellyfin** | play-history signal — blocked until a music library exists in Jellyfin (milestone 6) |
| **Jellyfin** | play-history signal (prioritised by Björn 2026-07-31) — infra prerequisite: mount `/srv/storage1/Music` into the jellyfin container + create a music library (container recreate, needs Björn's OK) |
## UI sketch (Jellyseerr-like)
@@ -59,10 +59,11 @@ stack `.env`, signed session cookie).
3. **Search page** — MusicBrainz search enriched with YT Music
thumbnails/links, add/play/open actions
4. **On-demand suggestions** — "generera nu": Gemini prompt built from
Lidarr library + Youtubarr playlist adds
Lidarr library + Youtubarr playlist adds + Jellyfin play history
(used as soon as it's configured)
5. **Scheduler + history** — cron-generated batches, history page
6. **Jellyfin signal** — recent music plays feed the prompt (requires
music library in Jellyfin)
6. **Jellyfin music library** — infra step on the Pi5 (mount + library)
so the play-history signal has data
## Roadmap / expansions (proposed — Björn confirms/strikes)

24
docker/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# Multi-stage: frontend-bygge -> backend-deps -> slim runtime (arm64 på Pi5)
ARG BUILD_CPUS=2
FROM node:22-slim AS frontend
WORKDIR /f
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
FROM node:22-slim AS backend
WORKDIR /app
COPY backend/package.json backend/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY backend/src ./src
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=backend /app ./
COPY --from=frontend /f/dist ./public
EXPOSE 3000
USER node
CMD ["node", "src/server.js"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="sv">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>lyssnarr</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1383
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "lyssnarr-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"vite": "^6.3.5"
}
}

76
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,76 @@
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
async function logout() {
await fetch('/api/logout', { method: 'POST' })
router.push({ name: 'login' })
}
</script>
<template>
<header v-if="route.name !== 'login'" class="topbar">
<div class="brand">
<span class="note"></span> lyssnarr
</div>
<div class="search">
<input
type="search"
placeholder="Sök artister, album, låtar… (kommer i milstolpe 3)"
disabled
/>
</div>
<button class="ghost" @click="logout">Logga ut</button>
</header>
<main>
<router-view />
</main>
</template>
<style scoped>
.topbar {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 0.9rem 1.5rem;
background: rgba(17, 24, 39, 0.85);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.brand {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
}
.note {
color: var(--accent-hover);
}
.search {
flex: 1;
max-width: 34rem;
}
.ghost {
background: transparent;
color: var(--text-dim);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.ghost:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
}
main {
padding: 1.5rem;
}
</style>

6
frontend/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router.js'
import './style.css'
createApp(App).use(router).mount('#app')

View File

@@ -0,0 +1,45 @@
<script setup>
// Milstolpe 1: tomt skal. Förslags-korten (Gemini) kommer i milstolpe 4,
// sök i milstolpe 3.
</script>
<template>
<section>
<h2>Upptäck</h2>
<div class="empty">
<p class="big">🎧</p>
<p>Inga förslag ännu.</p>
<p class="dim">
Här landar dina schemalagda Gemini-förslag baserade vad du
lyssnat och lagt till i dina YouTube Music-spellistor.
</p>
</div>
</section>
</template>
<style scoped>
h2 {
margin: 0 0 1rem;
}
.empty {
display: grid;
place-items: center;
gap: 0.25rem;
padding: 4rem 1rem;
background: var(--card);
border-radius: 1rem;
border: 1px dashed rgba(255, 255, 255, 0.12);
text-align: center;
}
.big {
font-size: 3rem;
margin: 0;
}
.dim {
color: var(--text-dim);
max-width: 32rem;
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const password = ref('')
const error = ref('')
const busy = ref(false)
async function login() {
busy.value = true
error.value = ''
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: password.value }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
error.value = body.error ?? 'inloggningen misslyckades'
return
}
router.push({ name: 'discover' })
} finally {
busy.value = false
}
}
</script>
<template>
<div class="wrap">
<form class="card" @submit.prevent="login">
<h1><span class="note"></span> lyssnarr</h1>
<p class="dim">Logga in för att upptäcka ny musik</p>
<input
v-model="password"
type="password"
placeholder="Lösenord"
autofocus
autocomplete="current-password"
/>
<button :disabled="busy || !password">Logga in</button>
<p v-if="error" class="error">{{ error }}</p>
</form>
</div>
</template>
<style scoped>
.wrap {
min-height: 80vh;
display: grid;
place-items: center;
}
.card {
display: flex;
flex-direction: column;
gap: 0.9rem;
width: min(22rem, 90vw);
padding: 2rem;
background: var(--card);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.06);
}
h1 {
margin: 0;
text-align: center;
}
.note {
color: var(--accent-hover);
}
.dim {
margin: 0;
text-align: center;
color: var(--text-dim);
}
.error {
margin: 0;
color: var(--danger);
text-align: center;
}
</style>

19
frontend/src/router.js Normal file
View File

@@ -0,0 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import DiscoverPage from './pages/DiscoverPage.vue'
import LoginPage from './pages/LoginPage.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'discover', component: DiscoverPage },
{ path: '/login', name: 'login', component: LoginPage },
],
})
router.beforeEach(async (to) => {
if (to.name === 'login') return true
const res = await fetch('/api/session')
const { authenticated } = await res.json()
if (!authenticated) return { name: 'login' }
return true
})

67
frontend/src/style.css Normal file
View File

@@ -0,0 +1,67 @@
/* Mörkt Jellyseerr-likt tema */
:root {
--bg: #111827;
--bg-deep: #0b1120;
--card: #1f2937;
--card-hover: #273449;
--text: #e5e7eb;
--text-dim: #9ca3af;
--accent: #6366f1;
--accent-hover: #818cf8;
--danger: #f87171;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100vh;
}
body {
background: linear-gradient(180deg, var(--bg) 0%, var(--bg-deep) 100%);
background-attachment: fixed;
color: var(--text);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
a {
color: var(--accent-hover);
text-decoration: none;
}
button {
font: inherit;
border: 0;
border-radius: 0.5rem;
padding: 0.55rem 1.1rem;
background: var(--accent);
color: #fff;
cursor: pointer;
transition: background 0.15s;
}
button:hover {
background: var(--accent-hover);
}
input[type='text'],
input[type='password'],
input[type='search'] {
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.55rem 0.9rem;
outline: none;
width: 100%;
}
input:focus {
border-color: var(--accent);
}

11
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:3000',
},
},
})

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "lyssnarr",
"private": true,
"version": "0.1.0",
"description": "Musik-discovery för Pi5 — Jellyseerr-lik UI, Gemini-förslag, Lidarr/YT Music/Jellyfin",
"scripts": {
"setup": "npm --prefix backend install && npm --prefix frontend install",
"dev:backend": "npm --prefix backend run dev",
"dev:frontend": "npm --prefix frontend run dev",
"build": "npm --prefix frontend run build && rm -rf build && mkdir -p build && cp -r frontend/dist build/public && cp -r backend/src build/server",
"test": "npm --prefix backend test"
}
}