From d8a5089eaf294d249ca2a72cc521a3f56314035b Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 1 Aug 2026 18:33:37 +0200 Subject: [PATCH] =?UTF-8?q?ntfy-notiser:=20skicka=20kort=20notis=20n=C3=A4?= =?UTF-8?q?r=20n=C3=A5got=20l=C3=A4ggs=20till=20f=C3=B6r=20nedladdning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Konfigureras på inställningssidan (server-URL, topic, på/av-toggle, testknapp) med NTFY_URL/NTFY_TOPIC som env-fallback. Notisen är fire-and-forget och kan aldrig fälla själva tillägget. Co-Authored-By: Claude Fable 5 --- README.md | 1 + backend/src/app.js | 15 +++ backend/src/config.js | 4 + backend/src/ntfy.js | 34 ++++++ backend/src/testers.js | 23 ++++ backend/test/ntfy.test.js | 183 ++++++++++++++++++++++++++++ frontend/src/pages/SettingsPage.vue | 57 +++++++-- 7 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 backend/src/ntfy.js create mode 100644 backend/test/ntfy.test.js diff --git a/README.md b/README.md index 0d0b204..262d0ac 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ repo. Key variables (full list in `doc/plan.md`): | `LIDARR_URL` / `LIDARR_API_KEY` | Lidarr instance to add music to | | `SESSION_SECRET` | cookie signing | | `APP_PASSWORD_HASH` | bcrypt hash for the single-user login | +| `NTFY_URL` / `NTFY_TOPIC` | defaults for the "tillagt för nedladdning" notiser — override + on/off toggle on the settings page | ## Deploy diff --git a/backend/src/app.js b/backend/src/app.js index e2fa513..f471686 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -10,6 +10,7 @@ import { lidarrClient, LidarrError } from './lidarr.js' import { mbClient } from './musicbrainz.js' import { mapLidarrSearch } from './mapping.js' import { settingsStore, suggestConfig } from './settings.js' +import { ntfyNotifier } from './ntfy.js' import { makeTesters } from './testers.js' import { ytmusicFlow } from './ytmusic.js' import { suggestEngine } from './suggest.js' @@ -51,6 +52,7 @@ export function buildApp(config, deps = {}) { const mb = deps.musicbrainz ?? mbClient() const store = deps.store ?? settingsStore(config.dataDir ?? '/data') const testers = deps.testers ?? makeTesters({ config, store }) + const ntfy = deps.ntfy ?? ntfyNotifier({ config, store }) const ytmusic = deps.ytmusic ?? ytmusicFlow({ config, store }) const suggest = deps.suggest ?? suggestEngine({ config, store, lidarr }) const app = Fastify({ logger: true, trustProxy: true }) @@ -142,6 +144,7 @@ export function buildApp(config, deps = {}) { rootFolderPath: config.lidarr.rootFolder, } + let label if (addKind === 'artist') { if (hit.artist.id) return { ok: true, already: true } await lidarr.addArtist({ @@ -150,6 +153,7 @@ export function buildApp(config, deps = {}) { monitored: true, addOptions: { monitor: 'all', searchForMissingAlbums: true }, }) + label = `${hit.artist.artistName} (hela artisten)` } else { if (hit.album.id) return { ok: true, already: true } await lidarr.addAlbum({ @@ -158,7 +162,12 @@ export function buildApp(config, deps = {}) { artist: { ...hit.album.artist, ...profile, monitored: false }, addOptions: { searchForNewAlbum: true }, }) + label = `${hit.album.artist?.artistName ?? 'Okänd artist'} – ${hit.album.title}` } + // fire-and-forget: notisen får inte fördröja eller fälla svaret + ntfy.publish('Tillagt för nedladdning', label).catch((err) => + req.log.warn({ err }, 'ntfy-notis misslyckades'), + ) return { ok: true, already: false } } catch (err) { if (err instanceof LidarrError && err.status === 400 && /already/i.test(err.message)) { @@ -188,6 +197,7 @@ export function buildApp(config, deps = {}) { connected: existsSync(ytmusic.browserPath) || existsSync(ytmusic.oauthPath), browserConnected: existsSync(ytmusic.browserPath), }, + ntfy: ntfy.settings(), suggest: suggestConfig(s), } }) @@ -207,6 +217,11 @@ export function buildApp(config, deps = {}) { clientId: body.ytmusic?.clientId, clientSecret: body.ytmusic?.clientSecret, }, + ntfy: { + url: body.ntfy?.url, + topic: body.ntfy?.topic, + enabled: typeof body.ntfy?.enabled === 'boolean' ? body.ntfy.enabled : undefined, + }, suggest: sanitizeSuggest(body.suggest), }) return { ok: true } diff --git a/backend/src/config.js b/backend/src/config.js index cd82081..540bb46 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -20,6 +20,10 @@ export function loadConfig(env = process.env) { apiKey: env.GEMINI_API_KEY ?? '', model: env.GEMINI_MODEL ?? 'gemini-2.5-flash', }, + ntfy: { + url: env.NTFY_URL ?? '', + topic: env.NTFY_TOPIC ?? '', + }, youtube: { apiKey: env.YOUTUBE_API_KEY ?? '', }, diff --git a/backend/src/ntfy.js b/backend/src/ntfy.js new file mode 100644 index 0000000..2811595 --- /dev/null +++ b/backend/src/ntfy.js @@ -0,0 +1,34 @@ +// Ntfy-notiser: kort meddelande till en topic när något läggs till för +// nedladdning. Konfigureras på inställningssidan (env som fallback) och +// får aldrig stoppa själva tillägget om ntfy inte svarar. + +// Effektiv ntfy-konfig = sparade inställningar med env som fallback. +// enabled är av tills man uttryckligen slår på det i UI:t. +export function ntfySettings(config, settings) { + const s = settings.ntfy ?? {} + return { + enabled: s.enabled === true, + url: (s.url || config.ntfy?.url || '').replace(/\/$/, ''), + topic: s.topic || config.ntfy?.topic || '', + } +} + +export function ntfyNotifier({ config, store, fetchImpl = fetch }) { + return { + settings: () => ntfySettings(config, store.load()), + + // skickar bara om notiser är påslagna och komplett konfigurerade; + // JSON-publicering mot serverroten så åäö i titlar funkar + async publish(title, message, tags = ['musical_note']) { + const cfg = ntfySettings(config, store.load()) + if (!cfg.enabled || !cfg.url || !cfg.topic) return false + const res = await fetchImpl(cfg.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic: cfg.topic, title, message, tags }), + }) + if (!res.ok) throw new Error(`ntfy svarade ${res.status}`) + return true + }, + } +} diff --git a/backend/src/testers.js b/backend/src/testers.js index 12f4080..d956f0a 100644 --- a/backend/src/testers.js +++ b/backend/src/testers.js @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { effectiveConfig } from './settings.js' +import { ntfySettings } from './ntfy.js' // Live-tester för varje integration — används av "Spara & testa" på // inställningssidan. Returnerar alltid {ok, message} (+ users för Jellyfin). @@ -95,6 +96,28 @@ export function makeTesters({ config, store, fetchImpl = fetch }) { return { ok: true, message: 'ok — YT Music-kopplingen fungerar' } }, + // skickar en riktig testnotis (även om notiser är avstängda) så att + // konfigurationen kan verifieras innan man slår på dem + async ntfy() { + const cfg = ntfySettings(config, store.load()) + if (!cfg.url || !cfg.topic) return { ok: false, message: 'URL och topic behövs — spara först' } + let res + try { + res = await fetchImpl(cfg.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic: cfg.topic, title: 'Lyssnarr', message: 'testnotis ✓', tags: ['musical_note'] }), + }) + } catch { + return { ok: false, message: `kunde inte nå ${cfg.url}` } + } + if (!res.ok) return { ok: false, message: `ntfy svarade ${res.status}` } + return { + ok: true, + message: cfg.enabled ? 'ok — testnotis skickad' : 'ok — testnotis skickad (men notiser är avstängda)', + } + }, + async youtubarr() { let res try { diff --git a/backend/test/ntfy.test.js b/backend/test/ntfy.test.js new file mode 100644 index 0000000..74b5c14 --- /dev/null +++ b/backend/test/ntfy.test.js @@ -0,0 +1,183 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { buildApp } from '../src/app.js' +import { settingsStore } from '../src/settings.js' +import { ntfySettings, ntfyNotifier } from '../src/ntfy.js' +import { makeTesters } from '../src/testers.js' + +function tmpDataDir() { + return mkdtempSync(path.join(tmpdir(), 'lyssnarr-test-')) +} + +const baseConfig = { + appPassword: 'testlösen', + sessionSecret: 'x'.repeat(32), + dataDir: '/tmp', + lidarr: { url: 'http://stub', apiKey: 'k', qualityProfileId: 3, metadataProfileId: 1, rootFolder: '/music' }, + jellyfin: { url: 'http://jellyfin:8096', apiKey: '' }, + gemini: { apiKey: '', model: 'gemini-2.5-flash' }, + ntfy: { url: 'https://ntfy.env-fallback', topic: '' }, +} + +async function loggedIn(app) { + const login = await app.inject({ method: 'POST', url: '/api/login', payload: { password: 'testlösen' } }) + return login.headers['set-cookie'] +} + +test('ntfySettings: sparat har företräde, env är fallback, enabled kräver explicit true', () => { + const def = ntfySettings(baseConfig, {}) + assert.equal(def.enabled, false) + assert.equal(def.url, 'https://ntfy.env-fallback') + + const s = ntfySettings(baseConfig, { ntfy: { url: 'https://ntfy.ui/', topic: 'media-hamtningar', enabled: true } }) + assert.equal(s.enabled, true) + assert.equal(s.url, 'https://ntfy.ui', 'avslutande / ska trimmas') + assert.equal(s.topic, 'media-hamtningar') + + const utanConfig = ntfySettings({}, {}) + assert.equal(utanConfig.url, '', 'config utan ntfy-sektion får inte krascha') +}) + +test('publish skickar bara när påslagen och komplett', async () => { + const store = settingsStore(tmpDataDir()) + const sent = [] + const fetchImpl = async (url, opts) => { + sent.push({ url, body: JSON.parse(opts.body) }) + return { ok: true } + } + const notifier = ntfyNotifier({ config: baseConfig, store, fetchImpl }) + + assert.equal(await notifier.publish('t', 'm'), false, 'avstängd => inget skickas') + assert.equal(sent.length, 0) + + store.save({ ntfy: { url: 'https://ntfy.test', topic: 'media-hamtningar', enabled: true } }) + assert.equal(await notifier.publish('Tillagt', 'Powerwolf – Interludium'), true) + assert.equal(sent.length, 1) + assert.deepEqual(sent[0].body, { + topic: 'media-hamtningar', + title: 'Tillagt', + message: 'Powerwolf – Interludium', + tags: ['musical_note'], + }) +}) + +test('POST /api/add notifierar vid nytt tillägg men inte vid dubblett', async () => { + const hit = { + album: { + foreignAlbumId: 'mbid-album-1', + title: 'Interludium', + artist: { artistName: 'Powerwolf', foreignArtistId: 'mbid-artist-1' }, + }, + } + const lidarr = { + search: async () => [hit], + addAlbum: async () => ({}), + } + const published = [] + const ntfy = { + settings: () => ({ enabled: true, url: 'x', topic: 'y' }), + publish: async (title, message) => { + published.push({ title, message }) + return true + }, + } + const app = buildApp(baseConfig, { lidarr, musicbrainz: {}, store: settingsStore(tmpDataDir()), ntfy }) + const cookie = await loggedIn(app) + + const res = await app.inject({ + method: 'POST', + url: '/api/add', + headers: { cookie }, + payload: { kind: 'album', mbid: 'mbid-album-1' }, + }) + assert.equal(res.statusCode, 200) + assert.deepEqual(res.json(), { ok: true, already: false }) + // fire-and-forget: ge event-loopen ett varv + await new Promise((r) => setImmediate(r)) + assert.equal(published.length, 1) + assert.equal(published[0].title, 'Tillagt för nedladdning') + assert.equal(published[0].message, 'Powerwolf – Interludium') + + hit.album.id = 42 // finns redan i Lidarr + const dup = await app.inject({ + method: 'POST', + url: '/api/add', + headers: { cookie }, + payload: { kind: 'album', mbid: 'mbid-album-1' }, + }) + assert.deepEqual(dup.json(), { ok: true, already: true }) + await new Promise((r) => setImmediate(r)) + assert.equal(published.length, 1, 'dubblett ska inte notifiera') + await app.close() +}) + +test('misslyckad notis fäller inte add-svaret', async () => { + const hit = { + artist: { foreignArtistId: 'mbid-artist-1', artistName: 'Powerwolf' }, + } + const lidarr = { search: async () => [hit], addArtist: async () => ({}) } + const ntfy = { + settings: () => ({ enabled: true, url: 'x', topic: 'y' }), + publish: async () => { + throw new Error('ntfy nere') + }, + } + const app = buildApp(baseConfig, { lidarr, musicbrainz: {}, store: settingsStore(tmpDataDir()), ntfy }) + 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 }) + await app.close() +}) + +test('PUT + GET /api/settings hanterar ntfy-sektionen, enabled=false består', async () => { + const store = settingsStore(tmpDataDir()) + const app = buildApp(baseConfig, { lidarr: {}, musicbrainz: {}, store }) + const cookie = await loggedIn(app) + + await app.inject({ + method: 'PUT', + url: '/api/settings', + headers: { cookie }, + payload: { ntfy: { url: 'https://ntfy.test', topic: 'media-hamtningar', enabled: true } }, + }) + let body = (await app.inject({ method: 'GET', url: '/api/settings', headers: { cookie } })).json() + assert.deepEqual(body.ntfy, { enabled: true, url: 'https://ntfy.test', topic: 'media-hamtningar' }) + + await app.inject({ + method: 'PUT', + url: '/api/settings', + headers: { cookie }, + payload: { ntfy: { enabled: false } }, + }) + body = (await app.inject({ method: 'GET', url: '/api/settings', headers: { cookie } })).json() + assert.equal(body.ntfy.enabled, false, 'false ska sparas, inte ignoreras') + assert.equal(body.ntfy.topic, 'media-hamtningar', 'övriga fält ska behållas') + await app.close() +}) + +test('ntfy-testern skickar testnotis och skiljer på saknad konfig', async () => { + const store = settingsStore(tmpDataDir()) + const sent = [] + const fetchImpl = async (url, opts) => { + sent.push(JSON.parse(opts.body)) + return { ok: true } + } + const testers = makeTesters({ config: { ...baseConfig, ntfy: { url: '', topic: '' } }, store, fetchImpl }) + assert.equal((await testers.ntfy()).ok, false, 'utan konfig => fel') + + store.save({ ntfy: { url: 'https://ntfy.test', topic: 'media-hamtningar' } }) + const out = await testers.ntfy() + assert.equal(out.ok, true) + assert.match(out.message, /avstängda/, 'ska påminna om att notiser inte är påslagna') + assert.equal(sent.length, 1) + assert.equal(sent[0].topic, 'media-hamtningar') +}) diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue index 0c27fc7..f3a75c9 100644 --- a/frontend/src/pages/SettingsPage.vue +++ b/frontend/src/pages/SettingsPage.vue @@ -4,7 +4,8 @@ import { onMounted, ref } from 'vue' const gemini = ref({ apiKey: '', model: '' }) const jellyfin = ref({ url: '', apiKey: '', userId: '' }) const ytmusic = ref({ clientId: '', clientSecret: '' }) -const saved = ref({ gemini: {}, jellyfin: {}, ytmusic: {}, suggest: {} }) +const ntfy = ref({ url: '', topic: '', enabled: false }) +const saved = ref({ gemini: {}, jellyfin: {}, ytmusic: {}, ntfy: {}, suggest: {} }) const suggestCfg = ref(null) const SUGGEST_META = { @@ -42,6 +43,11 @@ onMounted(async () => { gemini.value.model = saved.value.gemini.model jellyfin.value.url = saved.value.jellyfin.url jellyfin.value.userId = saved.value.jellyfin.userId + ntfy.value = { + url: saved.value.ntfy?.url ?? '', + topic: saved.value.ntfy?.topic ?? '', + enabled: saved.value.ntfy?.enabled ?? false, + } suggestCfg.value = saved.value.suggest }) @@ -61,14 +67,22 @@ async function saveAndTest(service) { const body = service === 'gemini' ? { gemini: { apiKey: gemini.value.apiKey || null, model: gemini.value.model || null } } - : { - jellyfin: { - url: jellyfin.value.url || null, - apiKey: jellyfin.value.apiKey || null, - userId: jellyfin.value.userId || null, - userName: jfUsers.value.find((u) => u.id === jellyfin.value.userId)?.name ?? null, - }, - } + : service === 'ntfy' + ? { + ntfy: { + url: ntfy.value.url || null, + topic: ntfy.value.topic || null, + enabled: Boolean(ntfy.value.enabled), + }, + } + : { + jellyfin: { + url: jellyfin.value.url || null, + apiKey: jellyfin.value.apiKey || null, + userId: jellyfin.value.userId || null, + userName: jfUsers.value.find((u) => u.id === jellyfin.value.userId)?.name ?? null, + }, + } await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -289,6 +303,31 @@ async function connectYtMusic() { +
+

Notiser (ntfy) på ✓

+

+ Skickar en kort notis till en ntfy-topic när något läggs till för + nedladdning. Samma topic som resten av media-stacken använder för + hämtningar. +

+ + + +
+ + {{ results.ntfy.message }} +
+
+

YouTube Music