ntfy-notiser: skicka kort notis när något läggs till för nedladdning
All checks were successful
build-and-push / build (push) Successful in 17s
All checks were successful
build-and-push / build (push) Successful in 17s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ repo. Key variables (full list in `doc/plan.md`):
|
|||||||
| `LIDARR_URL` / `LIDARR_API_KEY` | Lidarr instance to add music to |
|
| `LIDARR_URL` / `LIDARR_API_KEY` | Lidarr instance to add music to |
|
||||||
| `SESSION_SECRET` | cookie signing |
|
| `SESSION_SECRET` | cookie signing |
|
||||||
| `APP_PASSWORD_HASH` | bcrypt hash for the single-user login |
|
| `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
|
## Deploy
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { lidarrClient, LidarrError } from './lidarr.js'
|
|||||||
import { mbClient } from './musicbrainz.js'
|
import { mbClient } from './musicbrainz.js'
|
||||||
import { mapLidarrSearch } from './mapping.js'
|
import { mapLidarrSearch } from './mapping.js'
|
||||||
import { settingsStore, suggestConfig } from './settings.js'
|
import { settingsStore, suggestConfig } from './settings.js'
|
||||||
|
import { ntfyNotifier } from './ntfy.js'
|
||||||
import { makeTesters } from './testers.js'
|
import { makeTesters } from './testers.js'
|
||||||
import { ytmusicFlow } from './ytmusic.js'
|
import { ytmusicFlow } from './ytmusic.js'
|
||||||
import { suggestEngine } from './suggest.js'
|
import { suggestEngine } from './suggest.js'
|
||||||
@@ -51,6 +52,7 @@ export function buildApp(config, deps = {}) {
|
|||||||
const mb = deps.musicbrainz ?? mbClient()
|
const mb = deps.musicbrainz ?? mbClient()
|
||||||
const store = deps.store ?? settingsStore(config.dataDir ?? '/data')
|
const store = deps.store ?? settingsStore(config.dataDir ?? '/data')
|
||||||
const testers = deps.testers ?? makeTesters({ config, store })
|
const testers = deps.testers ?? makeTesters({ config, store })
|
||||||
|
const ntfy = deps.ntfy ?? ntfyNotifier({ config, store })
|
||||||
const ytmusic = deps.ytmusic ?? ytmusicFlow({ config, store })
|
const ytmusic = deps.ytmusic ?? ytmusicFlow({ config, store })
|
||||||
const suggest = deps.suggest ?? suggestEngine({ config, store, lidarr })
|
const suggest = deps.suggest ?? suggestEngine({ config, store, lidarr })
|
||||||
const app = Fastify({ logger: true, trustProxy: true })
|
const app = Fastify({ logger: true, trustProxy: true })
|
||||||
@@ -142,6 +144,7 @@ export function buildApp(config, deps = {}) {
|
|||||||
rootFolderPath: config.lidarr.rootFolder,
|
rootFolderPath: config.lidarr.rootFolder,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let label
|
||||||
if (addKind === 'artist') {
|
if (addKind === 'artist') {
|
||||||
if (hit.artist.id) return { ok: true, already: true }
|
if (hit.artist.id) return { ok: true, already: true }
|
||||||
await lidarr.addArtist({
|
await lidarr.addArtist({
|
||||||
@@ -150,6 +153,7 @@ export function buildApp(config, deps = {}) {
|
|||||||
monitored: true,
|
monitored: true,
|
||||||
addOptions: { monitor: 'all', searchForMissingAlbums: true },
|
addOptions: { monitor: 'all', searchForMissingAlbums: true },
|
||||||
})
|
})
|
||||||
|
label = `${hit.artist.artistName} (hela artisten)`
|
||||||
} else {
|
} else {
|
||||||
if (hit.album.id) return { ok: true, already: true }
|
if (hit.album.id) return { ok: true, already: true }
|
||||||
await lidarr.addAlbum({
|
await lidarr.addAlbum({
|
||||||
@@ -158,7 +162,12 @@ export function buildApp(config, deps = {}) {
|
|||||||
artist: { ...hit.album.artist, ...profile, monitored: false },
|
artist: { ...hit.album.artist, ...profile, monitored: false },
|
||||||
addOptions: { searchForNewAlbum: true },
|
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 }
|
return { ok: true, already: false }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof LidarrError && err.status === 400 && /already/i.test(err.message)) {
|
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),
|
connected: existsSync(ytmusic.browserPath) || existsSync(ytmusic.oauthPath),
|
||||||
browserConnected: existsSync(ytmusic.browserPath),
|
browserConnected: existsSync(ytmusic.browserPath),
|
||||||
},
|
},
|
||||||
|
ntfy: ntfy.settings(),
|
||||||
suggest: suggestConfig(s),
|
suggest: suggestConfig(s),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -207,6 +217,11 @@ export function buildApp(config, deps = {}) {
|
|||||||
clientId: body.ytmusic?.clientId,
|
clientId: body.ytmusic?.clientId,
|
||||||
clientSecret: body.ytmusic?.clientSecret,
|
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),
|
suggest: sanitizeSuggest(body.suggest),
|
||||||
})
|
})
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export function loadConfig(env = process.env) {
|
|||||||
apiKey: env.GEMINI_API_KEY ?? '',
|
apiKey: env.GEMINI_API_KEY ?? '',
|
||||||
model: env.GEMINI_MODEL ?? 'gemini-2.5-flash',
|
model: env.GEMINI_MODEL ?? 'gemini-2.5-flash',
|
||||||
},
|
},
|
||||||
|
ntfy: {
|
||||||
|
url: env.NTFY_URL ?? '',
|
||||||
|
topic: env.NTFY_TOPIC ?? '',
|
||||||
|
},
|
||||||
youtube: {
|
youtube: {
|
||||||
apiKey: env.YOUTUBE_API_KEY ?? '',
|
apiKey: env.YOUTUBE_API_KEY ?? '',
|
||||||
},
|
},
|
||||||
|
|||||||
34
backend/src/ntfy.js
Normal file
34
backend/src/ntfy.js
Normal file
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { effectiveConfig } from './settings.js'
|
import { effectiveConfig } from './settings.js'
|
||||||
|
import { ntfySettings } from './ntfy.js'
|
||||||
|
|
||||||
// Live-tester för varje integration — används av "Spara & testa" på
|
// Live-tester för varje integration — används av "Spara & testa" på
|
||||||
// inställningssidan. Returnerar alltid {ok, message} (+ users för Jellyfin).
|
// 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' }
|
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() {
|
async youtubarr() {
|
||||||
let res
|
let res
|
||||||
try {
|
try {
|
||||||
|
|||||||
183
backend/test/ntfy.test.js
Normal file
183
backend/test/ntfy.test.js
Normal file
@@ -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')
|
||||||
|
})
|
||||||
@@ -4,7 +4,8 @@ import { onMounted, ref } from 'vue'
|
|||||||
const gemini = ref({ apiKey: '', model: '' })
|
const gemini = ref({ apiKey: '', model: '' })
|
||||||
const jellyfin = ref({ url: '', apiKey: '', userId: '' })
|
const jellyfin = ref({ url: '', apiKey: '', userId: '' })
|
||||||
const ytmusic = ref({ clientId: '', clientSecret: '' })
|
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 suggestCfg = ref(null)
|
||||||
|
|
||||||
const SUGGEST_META = {
|
const SUGGEST_META = {
|
||||||
@@ -42,6 +43,11 @@ onMounted(async () => {
|
|||||||
gemini.value.model = saved.value.gemini.model
|
gemini.value.model = saved.value.gemini.model
|
||||||
jellyfin.value.url = saved.value.jellyfin.url
|
jellyfin.value.url = saved.value.jellyfin.url
|
||||||
jellyfin.value.userId = saved.value.jellyfin.userId
|
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
|
suggestCfg.value = saved.value.suggest
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -61,14 +67,22 @@ async function saveAndTest(service) {
|
|||||||
const body =
|
const body =
|
||||||
service === 'gemini'
|
service === 'gemini'
|
||||||
? { gemini: { apiKey: gemini.value.apiKey || null, model: gemini.value.model || null } }
|
? { gemini: { apiKey: gemini.value.apiKey || null, model: gemini.value.model || null } }
|
||||||
: {
|
: service === 'ntfy'
|
||||||
jellyfin: {
|
? {
|
||||||
url: jellyfin.value.url || null,
|
ntfy: {
|
||||||
apiKey: jellyfin.value.apiKey || null,
|
url: ntfy.value.url || null,
|
||||||
userId: jellyfin.value.userId || null,
|
topic: ntfy.value.topic || null,
|
||||||
userName: jfUsers.value.find((u) => u.id === jellyfin.value.userId)?.name ?? 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', {
|
await fetch('/api/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -289,6 +303,31 @@ async function connectYtMusic() {
|
|||||||
</template>
|
</template>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h3>Notiser (ntfy) <span v-if="saved.ntfy?.enabled" class="badge">på ✓</span></h3>
|
||||||
|
<p class="dim">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<label>Server-URL
|
||||||
|
<input v-model="ntfy.url" type="text" placeholder="https://ntfy.brasse-pc.eu" />
|
||||||
|
</label>
|
||||||
|
<label>Topic
|
||||||
|
<input v-model="ntfy.topic" type="text" placeholder="media-hamtningar" />
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input v-model="ntfy.enabled" type="checkbox" />
|
||||||
|
Skicka notis när något läggs till
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<button :disabled="busy.ntfy" @click="saveAndTest('ntfy')">
|
||||||
|
{{ busy.ntfy ? 'Testar…' : 'Spara & skicka testnotis' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="results.ntfy" :class="results.ntfy.ok ? 'ok' : 'fail'">{{ results.ntfy.message }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<h3>
|
<h3>
|
||||||
YouTube Music
|
YouTube Music
|
||||||
|
|||||||
Reference in New Issue
Block a user