Sångare-förslag + schema/antal/källor per typ + YT Music-skrivfix
Some checks failed
build-and-push / build (push) Failing after 12s

- Ny batch-typ 'singers': baseras på de 10 senaste unika sångarna ur
  gillat (YT Music) + spelat (Jellyfin); Gemini ger per sångare 5
  topplåtar + 3 relaterade artister, filtrerade mot Lidarr-biblioteket
  och gillalistan; klick på kortet öppnar modal med YT-länkar och
  Hämta-knappar
- Förslags-scheman per typ: intervall med enhet (timmar/dagar/månader),
  antal förslag (1-25) och källo-toggles; gamla intervalHours/
  trackIntervalDays migreras automatiskt; schemaläggaren kör alla tre
- Inställningar: Förslag-kortet omgjort till hopfällbara sektioner per
  typ (details/summary) så det inte blir överväldigande
- ytmusic: tydligt fel om oauth.json inte kan skrivas (root-ägd mapp
  var orsaken till 'inte kopplad än' — ägarskap fixat på Pi5)
- 28 tester gröna

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 02:19:58 +02:00
parent 6ff8e4d7ca
commit ac93b25827
12 changed files with 714 additions and 228 deletions

View File

@@ -9,7 +9,7 @@ import fastifyStatic from '@fastify/static'
import { lidarrClient, LidarrError } from './lidarr.js'
import { mbClient } from './musicbrainz.js'
import { mapLidarrSearch } from './mapping.js'
import { settingsStore } from './settings.js'
import { settingsStore, suggestConfig } from './settings.js'
import { makeTesters } from './testers.js'
import { ytmusicFlow } from './ytmusic.js'
import { suggestEngine } from './suggest.js'
@@ -18,6 +18,27 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OPEN_ROUTES = new Set(['/api/health', '/api/login', '/api/session'])
const SUGGEST_TYPES = ['mix', 'tracks', 'singers']
const SUGGEST_UNITS = new Set(['hours', 'days', 'months'])
function sanitizeSuggest(body) {
if (!body) return {}
const out = {}
for (const type of SUGGEST_TYPES) {
const t = body[type]
if (!t) continue
out[type] = {
every: Math.max(0, Number(t.every) || 0),
unit: SUGGEST_UNITS.has(t.unit) ? t.unit : 'days',
count: Math.min(25, Math.max(1, Number(t.count) || 10)),
sources: Object.fromEntries(
Object.entries(t.sources ?? {}).map(([k, v]) => [k, Boolean(v)]),
),
}
}
return out
}
function passwordOk(given, expected) {
if (typeof given !== 'string' || !expected) return false
const a = Buffer.from(given)
@@ -166,10 +187,7 @@ export function buildApp(config, deps = {}) {
clientSecretSet: Boolean(s.ytmusic?.clientSecret),
connected: existsSync(ytmusic.oauthPath),
},
suggest: {
intervalHours: s.suggest?.intervalHours ?? 24,
trackIntervalDays: s.suggest?.trackIntervalDays ?? 7,
},
suggest: suggestConfig(s),
}
})
@@ -188,22 +206,13 @@ export function buildApp(config, deps = {}) {
clientId: body.ytmusic?.clientId,
clientSecret: body.ytmusic?.clientSecret,
},
suggest: {
intervalHours:
body.suggest?.intervalHours === undefined || body.suggest?.intervalHours === null
? undefined
: Math.max(0, Number(body.suggest.intervalHours) || 0),
trackIntervalDays:
body.suggest?.trackIntervalDays === undefined || body.suggest?.trackIntervalDays === null
? undefined
: Math.max(0, Number(body.suggest.trackIntervalDays) || 0),
},
suggest: sanitizeSuggest(body.suggest),
})
return { ok: true }
})
app.post('/api/suggest/run', async (req, reply) => {
const type = req.body?.type === 'tracks' ? 'tracks' : 'mix'
const type = SUGGEST_TYPES.includes(req.body?.type) ? req.body.type : 'mix'
try {
const batch = await suggest.run('manuell', type)
return { ok: true, batchId: batch.id, count: batch.items.length }
@@ -214,14 +223,15 @@ export function buildApp(config, deps = {}) {
})
app.get('/api/suggest/latest', async () => {
const s = store.load()
const cfg = suggestConfig(store.load())
return {
items: suggest.latest(10, 'mix'),
tracks: suggest.latest(10, 'tracks'),
items: suggest.latest(cfg.mix.count, 'mix'),
tracks: suggest.latest(cfg.tracks.count, 'tracks'),
singers: suggest.latest(cfg.singers.count, 'singers'),
lastRun: suggest.lastRun('mix'),
lastTrackRun: suggest.lastRun('tracks'),
intervalHours: s.suggest?.intervalHours ?? 24,
trackIntervalDays: s.suggest?.trackIntervalDays ?? 7,
lastSingersRun: suggest.lastRun('singers'),
schedule: cfg,
}
})

View File

@@ -1,5 +1,6 @@
import { loadConfig } from './config.js'
import { buildApp } from './app.js'
import { suggestConfig, UNIT_MS } from './settings.js'
const config = loadConfig()
@@ -30,15 +31,12 @@ async function runIfDue(type, ms) {
}
setInterval(async () => {
const s = app.settingsStore.load().suggest ?? {}
try {
await runIfDue('mix', (s.intervalHours ?? 24) * 3600 * 1000)
} catch (err) {
app.log.warn({ err }, 'schemalagd mix-generering misslyckades')
}
try {
await runIfDue('tracks', (s.trackIntervalDays ?? 7) * 24 * 3600 * 1000)
} catch (err) {
app.log.warn({ err }, 'schemalagd låt-generering misslyckades')
const cfg = suggestConfig(app.settingsStore.load())
for (const type of ['mix', 'tracks', 'singers']) {
try {
await runIfDue(type, cfg[type].every * UNIT_MS[cfg[type].unit])
} catch (err) {
app.log.warn({ err }, `schemalagd ${type}-generering misslyckades`)
}
}
}, CHECK_MS)

View File

@@ -34,6 +34,46 @@ export function settingsStore(dataDir) {
return { load, save, file }
}
// Förslags-scheman: tre typer med intervall (värde + enhet), antal och
// källo-toggles. Gamla nycklarna intervalHours/trackIntervalDays migreras.
const SUGGEST_DEFAULTS = {
mix: { every: 24, unit: 'hours', count: 10, sources: { bibliotek: true, spelat: true, tillagt: true } },
tracks: { every: 7, unit: 'days', count: 10, sources: { gillade: true, mestSpelade: true } },
singers: { every: 1, unit: 'months', count: 8, sources: { gillade: true, spelat: true } },
}
export const UNIT_MS = {
hours: 3600 * 1000,
days: 24 * 3600 * 1000,
months: 30 * 24 * 3600 * 1000,
}
export function suggestConfig(settings) {
const s = settings.suggest ?? {}
const out = {}
for (const [type, def] of Object.entries(SUGGEST_DEFAULTS)) {
const t = s[type] ?? {}
let every = t.every
let unit = UNIT_MS[t.unit] ? t.unit : undefined
if (every === undefined) {
if (type === 'mix' && s.intervalHours !== undefined) {
every = Number(s.intervalHours)
unit ??= 'hours'
} else if (type === 'tracks' && s.trackIntervalDays !== undefined) {
every = Number(s.trackIntervalDays)
unit ??= 'days'
}
}
out[type] = {
every: Math.max(0, Number(every ?? def.every) || 0),
unit: unit ?? def.unit,
count: Math.min(25, Math.max(1, Number(t.count ?? def.count) || def.count)),
sources: { ...def.sources, ...(t.sources ?? {}) },
}
}
return out
}
// Effektiv konfig = sparade inställningar med env som fallback.
export function effectiveConfig(config, settings) {
return {

View File

@@ -1,16 +1,21 @@
import Database from 'better-sqlite3'
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import path from 'node:path'
import { effectiveConfig } from './settings.js'
import { effectiveConfig, suggestConfig } from './settings.js'
import { mapLidarrSearch } from './mapping.js'
// Förslagsmotorn: samlar smak-signaler (Lidarr-bibliotek, Jellyfin-historik,
// YT Music-tillägg via Youtubarrs databas), ber Gemini om nya förslag,
// berikar med MBID/omslag via Lidarrs metadata-API och cachar batcher
// på disk (/data/suggestions.json).
// Förslagsmotorn: tre batch-typer —
// mix artister/album från bibliotek + historik + YT-tillägg
// tracks enskilda låtar från senast gillat + mest spelat
// singers sångare från de 10 senaste unika sångarna i gillat/spelat;
// varje sångare får topplåtar + relaterade artister, filtrerade
// mot det man redan har i biblioteket/gillalistan
// Alla batcher cachas på disk (/data/suggestions.json).
export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
const file = path.join(config.dataDir ?? '/data', 'suggestions.json')
const cfgFor = (type) => suggestConfig(store.load())[type]
function loadBatches() {
try {
return JSON.parse(readFileSync(file, 'utf8'))
@@ -19,138 +24,119 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
}
}
function saveBatches(batches) {
function saveBatch(batches, batch) {
batches.unshift(batch)
mkdirSync(path.dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify(batches, null, 2))
return batch
}
async function collectSignals() {
// ---------- signal-insamling ----------
function ytRows(sql) {
if (!existsSync(config.youtubarr.dbPath)) return []
try {
const db = new Database(config.youtubarr.dbPath, { readonly: true, fileMustExist: true })
const rows = db.prepare(sql).all()
db.close()
return rows
} catch {
return []
}
}
function ytLiked(limit = 10) {
let rows = ytRows(
`SELECT ti.title, ti.artist_name_guess FROM youtubarr_trackitem ti
JOIN youtubarr_playlist p ON p.id = ti.playlist_id
WHERE p.playlist_id = 'LM' AND ti.blacklisted = 0
ORDER BY ti.id DESC LIMIT ${limit}`,
)
if (rows.length === 0) {
rows = ytRows(
`SELECT ti.title, ti.artist_name_guess FROM youtubarr_trackitem ti
WHERE ti.blacklisted = 0 ORDER BY ti.id DESC LIMIT ${limit}`,
)
}
return rows
}
async function jellyfinItems(sortBy, limit) {
const eff = effectiveConfig(config, store.load())
if (!eff.jellyfin.apiKey || !eff.jellyfin.userId) return []
try {
const url =
`${eff.jellyfin.url}/Users/${eff.jellyfin.userId}/Items` +
`?IncludeItemTypes=Audio&SortBy=${sortBy}&SortOrder=Descending` +
`&Filters=IsPlayed&Recursive=true&Limit=${limit}`
const res = await fetchImpl(url, { headers: { 'X-Emby-Token': eff.jellyfin.apiKey } })
if (!res.ok) return []
const js = await res.json()
return (js.Items ?? []).map((i) => ({
name: i.Name,
artist: (i.Artists ?? [])[0] ?? i.AlbumArtist ?? '?',
}))
} catch {
return []
}
}
async function libraryArtists() {
try {
return (await lidarr.artists()).map((a) => a.artistName)
} catch {
return []
}
}
async function collectMixSignals(sources) {
const signals = { bibliotek: [], nyligenSpelat: [], nyligenTillagt: [] }
try {
const artists = await lidarr.artists()
signals.bibliotek = artists.map((a) => a.artistName).slice(0, 150)
} catch {
/* biblioteket är tomt eller Lidarr nere — kör vidare */
if (sources.bibliotek) signals.bibliotek = (await libraryArtists()).slice(0, 150)
if (sources.spelat) {
signals.nyligenSpelat = (await jellyfinItems('DatePlayed', 30)).map((i) => `${i.artist} ${i.name}`)
}
if (eff.jellyfin.apiKey && eff.jellyfin.userId) {
try {
const url =
`${eff.jellyfin.url}/Users/${eff.jellyfin.userId}/Items` +
`?IncludeItemTypes=Audio&SortBy=DatePlayed&SortOrder=Descending` +
`&Filters=IsPlayed&Recursive=true&Limit=30`
const res = await fetchImpl(url, { headers: { 'X-Emby-Token': eff.jellyfin.apiKey } })
if (res.ok) {
const js = await res.json()
signals.nyligenSpelat = (js.Items ?? []).map(
(i) => `${(i.Artists ?? [])[0] ?? i.AlbumArtist ?? '?'} ${i.Name}`,
)
}
} catch {
/* Jellyfin nere — kör vidare */
}
if (sources.tillagt) {
signals.nyligenTillagt = ytRows(
`SELECT ti.title, ti.artist_name_guess, p.title AS playlist
FROM youtubarr_trackitem ti
LEFT JOIN youtubarr_playlist p ON p.id = ti.playlist_id
WHERE ti.blacklisted = 0 ORDER BY ti.id DESC LIMIT 40`,
).map((r) => `${r.artist_name_guess || '?'} ${r.title}${r.playlist ? ` [${r.playlist}]` : ''}`)
}
try {
if (existsSync(config.youtubarr.dbPath)) {
const db = new Database(config.youtubarr.dbPath, { readonly: true, fileMustExist: true })
const rows = db
.prepare(
`SELECT ti.title, ti.artist_name_guess, p.title AS playlist
FROM youtubarr_trackitem ti
LEFT JOIN youtubarr_playlist p ON p.id = ti.playlist_id
WHERE ti.blacklisted = 0
ORDER BY ti.id DESC LIMIT 40`,
)
.all()
db.close()
signals.nyligenTillagt = rows.map(
(r) => `${r.artist_name_guess || '?'} ${r.title}${r.playlist ? ` [${r.playlist}]` : ''}`,
)
}
} catch {
/* databasen saknas/låst — kör vidare */
}
return signals
}
async function collectTrackSignals() {
const eff = effectiveConfig(config, store.load())
async function collectTrackSignals(sources) {
const signals = { gillade: [], mestSpelade: [] }
try {
if (existsSync(config.youtubarr.dbPath)) {
const db = new Database(config.youtubarr.dbPath, { readonly: true, fileMustExist: true })
// Gillade = Liked Music-listan (LM) om den finns, annars senaste tilläggen
let rows = db
.prepare(
`SELECT ti.title, ti.artist_name_guess
FROM youtubarr_trackitem ti
JOIN youtubarr_playlist p ON p.id = ti.playlist_id
WHERE p.playlist_id = 'LM' AND ti.blacklisted = 0
ORDER BY ti.id DESC LIMIT 10`,
)
.all()
if (rows.length === 0) {
rows = db
.prepare(
`SELECT ti.title, ti.artist_name_guess
FROM youtubarr_trackitem ti
WHERE ti.blacklisted = 0
ORDER BY ti.id DESC LIMIT 10`,
)
.all()
}
db.close()
signals.gillade = rows.map((r) => `${r.artist_name_guess || '?'} ${r.title}`)
}
} catch {
/* kör vidare utan */
if (sources.gillade) {
signals.gillade = ytLiked(10).map((r) => `${r.artist_name_guess || '?'} ${r.title}`)
}
if (eff.jellyfin.apiKey && eff.jellyfin.userId) {
try {
const url =
`${eff.jellyfin.url}/Users/${eff.jellyfin.userId}/Items` +
`?IncludeItemTypes=Audio&SortBy=PlayCount&SortOrder=Descending` +
`&Filters=IsPlayed&Recursive=true&Limit=10`
const res = await fetchImpl(url, { headers: { 'X-Emby-Token': eff.jellyfin.apiKey } })
if (res.ok) {
const js = await res.json()
signals.mestSpelade = (js.Items ?? []).map(
(i) => `${(i.Artists ?? [])[0] ?? i.AlbumArtist ?? '?'} ${i.Name}`,
)
}
} catch {
/* kör vidare utan */
}
if (sources.mestSpelade) {
signals.mestSpelade = (await jellyfinItems('PlayCount', 10)).map((i) => `${i.artist} ${i.name}`)
}
return signals
}
function buildTrackPrompt(signals) {
return [
'Du är en kunnig musikrekommendatör. Utifrån låtarna nedan, föreslå',
'exakt 10 ENSKILDA LÅTAR (inte album eller artister) som användaren',
'troligen skulle gilla. Föreslå INTE låtar som redan finns i listorna.',
'Svara ENBART med en JSON-array i formatet:',
'[{"name":"låttitel","artist":"artistnamn","motivation":"..."}]',
'där "motivation" är en kort svensk mening om varför låten passar.',
'',
`Senast gillade låtar (YouTube Music): ${signals.gillade.join('; ') || '(ingen data)'}`,
'',
`Mest spelade låtar (Jellyfin): ${signals.mestSpelade.join('; ') || '(ingen data)'}`,
].join('\n')
async function collectSingerSignals(sources) {
const seen = new Set()
const singers = []
const add = (name) => {
const key = (name ?? '').trim().toLowerCase()
if (!key || key === '?' || seen.has(key)) return
seen.add(key)
singers.push(name.trim())
}
if (sources.gillade) for (const r of ytLiked(40)) add(r.artist_name_guess)
if (sources.spelat) for (const i of await jellyfinItems('DatePlayed', 40)) add(i.artist)
return { sangare: singers.slice(0, 10) }
}
function buildPrompt(signals) {
// ---------- prompter ----------
function buildMixPrompt(signals, count) {
return [
'Du är en kunnig musikrekommendatör. Utifrån signalerna nedan, föreslå',
'exakt 10 artister eller album som användaren troligen skulle gilla men',
`exakt ${count} artister eller album som användaren troligen skulle gilla men`,
'som INTE redan finns i biblioteket. Blanda gärna kända och smalare val.',
'Svara ENBART med en JSON-array i formatet:',
'[{"type":"artist"|"album","name":"...","artist":"...","motivation":"..."}]',
@@ -165,6 +151,41 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
].join('\n')
}
function buildTrackPrompt(signals, count) {
return [
'Du är en kunnig musikrekommendatör. Utifrån låtarna nedan, föreslå',
`exakt ${count} ENSKILDA LÅTAR (inte album eller artister) som användaren`,
'troligen skulle gilla. Föreslå INTE låtar som redan finns i listorna.',
'Svara ENBART med en JSON-array i formatet:',
'[{"name":"låttitel","artist":"artistnamn","motivation":"..."}]',
'där "motivation" är en kort svensk mening om varför låten passar.',
'',
`Senast gillade låtar (YouTube Music): ${signals.gillade.join('; ') || '(ingen data)'}`,
'',
`Mest spelade låtar (Jellyfin): ${signals.mestSpelade.join('; ') || '(ingen data)'}`,
].join('\n')
}
function buildSingersPrompt(signals, count, library) {
return [
'Du är en kunnig musikrekommendatör med fokus på SÅNGARE/VOKALISTER.',
`Utifrån sångarna nedan, föreslå exakt ${count} ANDRA sångare/vokalister`,
'som användaren troligen skulle uppskatta (liknande röst, genre eller',
'känsla). Föreslå INTE någon som redan finns i listorna nedan.',
'Svara ENBART med en JSON-array i formatet:',
'[{"name":"sångarens namn","motivation":"kort svensk mening",',
'"topTracks":[{"name":"låttitel","artist":"artist/band"}],',
'"relatedArtists":["artistnamn"]}]',
'med 5 topTracks och 3 relatedArtists per sångare.',
'',
`De senaste unika sångarna användaren gillat/lyssnat på: ${signals.sangare.join(', ') || '(ingen data)'}`,
'',
`Redan i biblioteket (undvik): ${library.join(', ') || '(tomt)'}`,
].join('\n')
}
// ---------- Gemini + berikning ----------
async function callGemini(prompt) {
const eff = effectiveConfig(config, store.load())
if (!eff.gemini.apiKey) throw new Error('Gemini-nyckel saknas — lägg in den under ⚙ Inställningar')
@@ -191,9 +212,17 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
return arr
}
async function enrich(rawItems) {
async function lidarrArtistHit(name) {
try {
return mapLidarrSearch(await lidarr.search(name), 'artist')[0] ?? null
} catch {
return null
}
}
async function enrichMix(rawItems, count) {
const out = []
for (const it of rawItems.slice(0, 12)) {
for (const it of rawItems.slice(0, count)) {
const kind = it.type === 'album' ? 'album' : 'artist'
const card = {
kind,
@@ -216,16 +245,16 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
if (kind === 'album' && hit.secondary) card.secondary = hit.secondary
}
} catch {
/* berikning är best-effort */
/* best-effort */
}
out.push(card)
}
return out
}
async function enrichTracks(rawItems) {
async function enrichTracks(rawItems, count) {
const out = []
for (const it of rawItems.slice(0, 12)) {
for (const it of rawItems.slice(0, count)) {
const card = {
kind: 'track',
name: it.name,
@@ -238,27 +267,53 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
poster: null,
inLibrary: false,
}
try {
const hit = mapLidarrSearch(await lidarr.search(it.artist ?? it.name), 'artist')[0]
if (hit) {
card.artistMbid = hit.mbid
card.mbid = hit.mbid
card.poster = hit.poster
}
} catch {
/* best-effort */
const hit = await lidarrArtistHit(it.artist ?? it.name)
if (hit) {
card.artistMbid = hit.mbid
card.mbid = hit.mbid
card.poster = hit.poster
}
out.push(card)
}
return out
}
function saveBatch(batches, batch) {
batches.unshift(batch)
saveBatches(batches)
return batch
async function enrichSingers(rawItems, count, library, likedTitles) {
const libSet = new Set(library.map((n) => n.toLowerCase()))
const likedSet = new Set(likedTitles.map((t) => t.toLowerCase()))
const out = []
for (const it of rawItems.slice(0, count + 3)) {
if (out.length >= count) break
const hit = await lidarrArtistHit(it.name)
if (hit?.inLibrary || libSet.has((it.name ?? '').toLowerCase())) continue
const related = []
for (const name of (it.relatedArtists ?? []).slice(0, 3)) {
if (libSet.has((name ?? '').toLowerCase())) continue
const rHit = await lidarrArtistHit(name)
if (rHit?.inLibrary) continue
related.push({ name, mbid: rHit?.mbid ?? null })
}
out.push({
kind: 'singer',
name: it.name,
secondary: '',
motivation: it.motivation ?? '',
year: '',
mbid: hit?.mbid ?? null,
poster: hit?.poster ?? null,
inLibrary: false,
topTracks: (it.topTracks ?? [])
.filter((t) => !likedSet.has((t.name ?? '').toLowerCase()))
.slice(0, 5)
.map((t) => ({ name: t.name, artist: t.artist ?? it.name })),
relatedArtists: related,
})
}
return out
}
// ---------- publikt api ----------
return {
file,
loadBatches,
@@ -277,27 +332,39 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
async run(trigger = 'manuell', type = 'mix') {
const batches = loadBatches()
const id = (batches[0]?.id ?? 0) + 1
const cfg = cfgFor(type)
const base = { id, type, createdAt: new Date().toISOString(), trigger }
if (type === 'tracks') {
const signals = await collectTrackSignals()
const raw = await callGemini(buildTrackPrompt(signals))
const items = await enrichTracks(raw)
const signals = await collectTrackSignals(cfg.sources)
const raw = await callGemini(buildTrackPrompt(signals, cfg.count))
const items = await enrichTracks(raw, cfg.count)
return saveBatch(batches, {
id,
type: 'tracks',
createdAt: new Date().toISOString(),
trigger,
...base,
signalCounts: { gillade: signals.gillade.length, mestSpelade: signals.mestSpelade.length },
items,
})
}
const signals = await collectSignals()
const raw = await callGemini(buildPrompt(signals))
const items = await enrich(raw)
if (type === 'singers') {
const signals = await collectSingerSignals(cfg.sources)
const library = await libraryArtists()
const likedTitles = ytLiked(60).map((r) => r.title)
const raw = await callGemini(buildSingersPrompt(signals, cfg.count, library))
const items = await enrichSingers(raw, cfg.count, library, likedTitles)
return saveBatch(batches, {
...base,
signalCounts: { sangare: signals.sangare.length, bibliotek: library.length },
items,
})
}
const signals = await collectMixSignals(cfg.sources)
const raw = await callGemini(buildMixPrompt(signals, cfg.count))
const items = await enrichMix(raw, cfg.count)
return saveBatch(batches, {
id,
...base,
type: 'mix',
createdAt: new Date().toISOString(),
trigger,
signalCounts: {
bibliotek: signals.bibliotek.length,
spelat: signals.nyligenSpelat.length,

View File

@@ -60,7 +60,14 @@ export function ytmusicFlow({ config, store, fetchImpl = fetch }) {
expires_in: js.expires_in,
expires_at: Math.floor(Date.now() / 1000) + (js.expires_in ?? 0),
}
writeFileSync(this.oauthPath, JSON.stringify(token, null, 2))
try {
writeFileSync(this.oauthPath, JSON.stringify(token, null, 2))
} catch (err) {
return {
status: 'error',
message: `godkänt av Google men kunde inte spara ${this.oauthPath}: ${err.code ?? err.message} (skrivrättigheter på Youtubarrs datamapp?)`,
}
}
return { status: 'klar' }
},
}

View File

@@ -4,7 +4,7 @@ import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { buildApp } from '../src/app.js'
import { settingsStore, effectiveConfig } from '../src/settings.js'
import { settingsStore, effectiveConfig, suggestConfig } from '../src/settings.js'
import { makeTesters } from '../src/testers.js'
function tmpDataDir() {
@@ -44,6 +44,22 @@ test('effectiveConfig: sparat har företräde, env är fallback', () => {
assert.equal(eff.gemini.model, 'gemini-2.5-flash')
})
test('suggestConfig: defaults, migrering av gamla nycklar och clamping', () => {
const def = suggestConfig({})
assert.equal(def.mix.every, 24)
assert.equal(def.tracks.unit, 'days')
assert.equal(def.singers.unit, 'months')
const legacy = suggestConfig({ suggest: { intervalHours: 12, trackIntervalDays: 3 } })
assert.equal(legacy.mix.every, 12)
assert.equal(legacy.mix.unit, 'hours')
assert.equal(legacy.tracks.every, 3)
const clamped = suggestConfig({ suggest: { mix: { count: 99, unit: 'veckor' } } })
assert.equal(clamped.mix.count, 25)
assert.equal(clamped.mix.unit, 'hours')
})
test('PUT + GET /api/settings maskerar hemligheter', async () => {
const store = settingsStore(tmpDataDir())
const app = buildApp(baseConfig, { lidarr: {}, musicbrainz: {}, store })

View File

@@ -134,6 +134,39 @@ test('run(tracks): gillade + mest spelade in i prompten, egen batch-typ', async
assert.equal(engine.lastRun('mix'), null)
})
test('run(singers): unika sångare i prompt, filtrering mot bibliotek + gillat', async () => {
const { engine, fetchImpl } = setup({
geminiResponse: JSON.stringify([
{
name: 'Floor Jansen',
motivation: 'Kraftfull sopran.',
topTracks: [
{ name: 'Gimme! Gimme! Gimme!', artist: 'ABBA' },
{ name: 'Storm', artist: 'Floor Jansen' },
],
relatedArtists: ['Powerwolf', 'Epica'],
},
]),
})
const batch = await engine.run('manuell', 'singers')
assert.match(fetchImpl.prompt, /SÅNGARE/)
assert.match(fetchImpl.prompt, /ABBA/, 'sångare ur gillat ska in i prompten')
assert.match(fetchImpl.prompt, /1000mods/, 'sångare ur spelat ska in i prompten')
assert.equal(batch.type, 'singers')
const s = batch.items[0]
assert.equal(s.kind, 'singer')
assert.equal(s.topTracks.length, 1, 'gillad låt (Gimme! Gimme! Gimme!) ska filtreras bort')
assert.equal(s.topTracks[0].name, 'Storm')
assert.deepEqual(
s.relatedArtists.map((a) => a.name),
['Epica'],
'Powerwolf finns i biblioteket och ska filtreras bort',
)
assert.equal(s.relatedArtists[0].mbid, 'mbid-x')
})
test('run utan Gemini-nyckel ger begripligt fel', async () => {
const { engine, dataDir } = setup()
const store = settingsStore(dataDir)

View File

@@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
const props = defineProps({ item: { type: Object, required: true } })
const emit = defineEmits(['detail'])
const state = ref('') // '' | 'busy' | 'done' | 'fanns' | 'fel'
const ytUrl = computed(() => {
@@ -19,7 +20,7 @@ async function grab() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: props.item.kind,
kind: props.item.kind === 'singer' ? 'artist' : props.item.kind,
mbid: props.item.mbid,
artistMbid: props.item.artistMbid,
albumMbid: props.item.albumMbid,
@@ -42,7 +43,7 @@ async function grab() {
<div class="poster">
<img v-if="item.poster" :src="item.poster" alt="" loading="lazy" @error="item.poster = null" />
<span v-else class="note"></span>
<span class="kind">{{ { artist: 'Artist', album: 'Album', track: 'Låt' }[item.kind] }}</span>
<span class="kind">{{ { artist: 'Artist', album: 'Album', track: 'Låt', singer: 'Sångare' }[item.kind] }}</span>
</div>
<div class="meta">
<strong :title="item.name">{{ item.name }}</strong>
@@ -60,6 +61,9 @@ async function grab() {
</button>
<a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music"> YT</a>
</div>
<button v-if="item.kind === 'singer'" class="more" @click="emit('detail', item)">
Topplåtar & liknande
</button>
</article>
</template>
@@ -174,4 +178,15 @@ async function grab() {
.yt:hover {
background: rgba(255, 60, 60, 0.12);
}
.more {
background: rgba(99, 102, 241, 0.15);
color: var(--accent-hover);
font-size: 0.85rem;
padding: 0.45rem;
}
.more:hover {
background: rgba(99, 102, 241, 0.3);
}
</style>

View File

@@ -0,0 +1,161 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({ singer: { type: Object, required: true } })
const emit = defineEmits(['close'])
const added = ref({})
function ytLink(name, artist) {
return `https://music.youtube.com/search?q=${encodeURIComponent(`${artist ?? ''} ${name}`.trim())}`
}
async function grabArtist(mbid, key) {
if (!mbid) return
added.value = { ...added.value, [key]: 'busy' }
try {
const res = await fetch('/api/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 'artist', mbid }),
})
const body = await res.json()
added.value = { ...added.value, [key]: res.ok ? (body.already ? 'fanns' : 'done') : 'fel' }
} catch {
added.value = { ...added.value, [key]: 'fel' }
}
}
const stateLabel = { busy: '…', done: '✓', fanns: '✓ finns', fel: 'fel' }
</script>
<template>
<div class="overlay" @click.self="emit('close')">
<div class="modal">
<header>
<h3>{{ singer.name }}</h3>
<button class="close" @click="emit('close')"></button>
</header>
<p class="dim">{{ singer.motivation }}</p>
<h4>Topplåtar <span class="dim">(sådant du inte redan har)</span></h4>
<ul>
<li v-for="(t, i) in singer.topTracks" :key="`t-${i}`">
<span class="name">{{ t.name }} <span class="dim"> {{ t.artist }}</span></span>
<a class="yt" :href="ytLink(t.name, t.artist)" target="_blank" rel="noopener"> YT</a>
</li>
<li v-if="!singer.topTracks?.length" class="dim">inget kvar efter filtrering</li>
</ul>
<h4>Liknande artister</h4>
<ul>
<li v-for="(a, i) in singer.relatedArtists" :key="`a-${i}`">
<span class="name">{{ a.name }}</span>
<span class="row-actions">
<a class="yt" :href="ytLink(a.name)" target="_blank" rel="noopener"> YT</a>
<button v-if="a.mbid" class="mini" :disabled="added[`a-${i}`]" @click="grabArtist(a.mbid, `a-${i}`)">
{{ stateLabel[added[`a-${i}`]] ?? ' Hämta' }}
</button>
</span>
</li>
<li v-if="!singer.relatedArtists?.length" class="dim">inget kvar efter filtrering</li>
</ul>
<div class="foot">
<button v-if="singer.mbid" class="mini" :disabled="added.self" @click="grabArtist(singer.mbid, 'self')">
{{ stateLabel[added.self] ?? ` Hämta ${singer.name} till Lidarr` }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: grid;
place-items: center;
z-index: 50;
padding: 1rem;
}
.modal {
background: var(--card);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 1.2rem 1.4rem;
width: min(30rem, 95vw);
max-height: 85vh;
overflow-y: auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
h3 {
margin: 0;
}
h4 {
margin: 1rem 0 0.4rem;
}
.close {
background: transparent;
color: var(--text-dim);
padding: 0.2rem 0.5rem;
}
.dim {
color: var(--text-dim);
font-weight: 400;
font-size: 0.9rem;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-actions {
display: flex;
gap: 0.4rem;
flex: 0 0 auto;
}
.yt {
color: #ff8080;
font-size: 0.85rem;
white-space: nowrap;
}
.mini {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
}
.foot {
margin-top: 1.2rem;
}
</style>

View File

@@ -1,25 +1,37 @@
<script setup>
import { onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue'
import SingerModal from '../components/SingerModal.vue'
const items = ref([])
const tracks = ref([])
const singers = ref([])
const lastRun = ref(null)
const lastTrackRun = ref(null)
const intervalHours = ref(24)
const trackIntervalDays = ref(7)
const lastSingersRun = ref(null)
const schedule = ref(null)
const busy = ref({})
const error = ref('')
const openSinger = ref(null)
const UNIT_LABEL = { hours: 'timme', days: 'dag', months: 'månad' }
function scheduleText(type) {
const cfg = schedule.value?.[type]
if (!cfg || !cfg.every) return 'schema: av'
return `schema: var ${cfg.every}:e ${UNIT_LABEL[cfg.unit] ?? cfg.unit}`
}
async function load() {
const res = await fetch('/api/suggest/latest')
const body = await res.json()
items.value = body.items
tracks.value = body.tracks
singers.value = body.singers
lastRun.value = body.lastRun
lastTrackRun.value = body.lastTrackRun
intervalHours.value = body.intervalHours
trackIntervalDays.value = body.trackIntervalDays
lastSingersRun.value = body.lastSingersRun
schedule.value = body.schedule
}
async function generate(type) {
@@ -57,11 +69,7 @@ onMounted(load)
<div class="head">
<h2>Upptäck</h2>
<div class="actions">
<span class="dim">
Senaste körning: {{ fmt(lastRun) }}
<template v-if="intervalHours"> · schema: var {{ intervalHours }}:e timme</template>
<template v-else> · schema: av</template>
</span>
<span class="dim">Senaste körning: {{ fmt(lastRun) }} · {{ scheduleText('mix') }}</span>
<router-link class="link" :to="{ name: 'suggestions' }">Alla förslag </router-link>
<button :disabled="busy.mix" @click="generate('mix')">
{{ busy.mix ? 'Genererar (kan ta en halv minut)' : ' Generera nya förslag' }}
@@ -88,11 +96,7 @@ onMounted(load)
<div class="head tracks-head">
<h2>Veckans låtar</h2>
<div class="actions">
<span class="dim">
Senaste körning: {{ fmt(lastTrackRun) }}
<template v-if="trackIntervalDays"> · schema: var {{ trackIntervalDays }}:e dag</template>
<template v-else> · schema: av</template>
</span>
<span class="dim">Senaste körning: {{ fmt(lastTrackRun) }} · {{ scheduleText('tracks') }}</span>
<button :disabled="busy.tracks" @click="generate('tracks')">
{{ busy.tracks ? 'Genererar' : '🎵 Generera låtförslag' }}
</button>
@@ -104,11 +108,40 @@ onMounted(load)
</div>
<div v-else class="empty small">
<p class="dim">
Inga låtförslag ännu genereras automatiskt varje vecka utifrån dina
senast gillade låtar (YT Music) och mest spelade (Jellyfin), eller
direkt via knappen.
Inga låtförslag ännu genereras automatiskt utifrån dina senast
gillade låtar (YT Music) och mest spelade (Jellyfin), eller direkt
via knappen.
</p>
</div>
<div class="head tracks-head">
<h2>Sångare</h2>
<div class="actions">
<span class="dim">Senaste körning: {{ fmt(lastSingersRun) }} · {{ scheduleText('singers') }}</span>
<button :disabled="busy.singers" @click="generate('singers')">
{{ busy.singers ? 'Genererar' : '🎤 Generera sångare' }}
</button>
</div>
</div>
<div v-if="singers.length" class="grid">
<MusicCard
v-for="(r, i) in singers"
:key="`s-${r.mbid ?? r.name}-${i}`"
:item="r"
@detail="openSinger = $event"
/>
</div>
<div v-else class="empty small">
<p class="dim">
Inga sångarförslag ännu baseras de 10 senaste unika sångarna i
det du gillat (YT Music) och lyssnat (Jellyfin). Klicka in en
sångare för topplåtar och liknande artister, filtrerat mot det du
redan har.
</p>
</div>
<SingerModal v-if="openSinger" :singer="openSinger" @close="openSinger = null" />
</section>
</template>

View File

@@ -5,8 +5,32 @@ const gemini = ref({ apiKey: '', model: '' })
const jellyfin = ref({ url: '', apiKey: '', userId: '' })
const ytmusic = ref({ clientId: '', clientSecret: '' })
const saved = ref({ gemini: {}, jellyfin: {}, ytmusic: {}, suggest: {} })
const intervalHours = ref(24)
const trackIntervalDays = ref(7)
const suggestCfg = ref(null)
const SUGGEST_META = {
mix: {
title: 'Artist & album (Upptäck)',
sources: {
bibliotek: 'Lidarr-biblioteket',
spelat: 'Nyligen spelat (Jellyfin)',
tillagt: 'Nyligen tillagt (YT Music)',
},
},
tracks: {
title: 'Låtförslag (Veckans låtar)',
sources: {
gillade: 'Senast gillade låtar (YT Music)',
mestSpelade: 'Mest spelade (Jellyfin)',
},
},
singers: {
title: 'Sångare',
sources: {
gillade: 'Sångare ur gillade låtar (YT Music)',
spelat: 'Sångare ur spelade låtar (Jellyfin)',
},
},
}
const results = ref({}) // service -> {ok, message}
const busy = ref({})
const jfUsers = ref([])
@@ -18,20 +42,14 @@ onMounted(async () => {
gemini.value.model = saved.value.gemini.model
jellyfin.value.url = saved.value.jellyfin.url
jellyfin.value.userId = saved.value.jellyfin.userId
intervalHours.value = saved.value.suggest?.intervalHours ?? 24
trackIntervalDays.value = saved.value.suggest?.trackIntervalDays ?? 7
suggestCfg.value = saved.value.suggest
})
async function saveInterval() {
async function saveSuggest() {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
suggest: {
intervalHours: Number(intervalHours.value),
trackIntervalDays: Number(trackIntervalDays.value),
},
}),
body: JSON.stringify({ suggest: suggestCfg.value }),
})
results.value = { ...results.value, suggest: { ok: true, message: 'sparat ✓' } }
}
@@ -199,21 +217,50 @@ async function connectYtMusic() {
</article>
<article class="card">
<h3>Förslags-schema</h3>
<h3>Förslag</h3>
<p class="dim">
Hur ofta nya Gemini-förslag hämtas automatiskt. Sätt 0 för att stänga
av du kan alltid klicka "Generera nya förslag" Upptäck-sidan.
Schema, antal och signalkällor per förslagstyp. Intervall 0 = av
du kan alltid generera manuellt från Upptäck-sidan.
</p>
<label>Artist/album-förslag intervall (timmar)
<input v-model="intervalHours" type="number" min="0" step="1" />
</label>
<label>Låtförslag ("Veckans låtar") intervall (dagar)
<input v-model="trackIntervalDays" type="number" min="0" step="1" />
</label>
<div class="row">
<button @click="saveInterval">Spara</button>
<span v-if="results.suggest" :class="results.suggest.ok ? 'ok' : 'fail'">{{ results.suggest.message }}</span>
</div>
<template v-if="suggestCfg">
<details v-for="(meta, type) in SUGGEST_META" :key="type" class="sub">
<summary>{{ meta.title }}
<span class="dim">
var {{ suggestCfg[type].every }}:e
{{ { hours: 'timme', days: 'dag', months: 'månad' }[suggestCfg[type].unit] }},
{{ suggestCfg[type].count }} förslag
</span>
</summary>
<div class="subbody">
<div class="pair">
<label>Intervall
<input v-model="suggestCfg[type].every" type="number" min="0" step="1" />
</label>
<label>Enhet
<select v-model="suggestCfg[type].unit">
<option value="hours">timmar</option>
<option value="days">dagar</option>
<option value="months">månader</option>
</select>
</label>
<label>Antal förslag
<input v-model="suggestCfg[type].count" type="number" min="1" max="25" step="1" />
</label>
</div>
<fieldset>
<legend class="dim">Baseras </legend>
<label v-for="(label, key) in meta.sources" :key="key" class="check">
<input v-model="suggestCfg[type].sources[key]" type="checkbox" />
{{ label }}
</label>
</fieldset>
</div>
</details>
<div class="row">
<button @click="saveSuggest">Spara förslags-inställningar</button>
<span v-if="results.suggest" :class="results.suggest.ok ? 'ok' : 'fail'">{{ results.suggest.message }}</span>
</div>
</template>
</article>
<article class="card">
@@ -360,4 +407,57 @@ select {
color: var(--text-dim);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.sub {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.6rem;
padding: 0.55rem 0.8rem;
}
.sub summary {
cursor: pointer;
font-weight: 600;
}
.subbody {
display: flex;
flex-direction: column;
gap: 0.7rem;
padding-top: 0.7rem;
}
.pair {
display: flex;
gap: 0.7rem;
flex-wrap: wrap;
}
.pair label {
flex: 1;
min-width: 7rem;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
legend {
padding: 0 0 0.3rem;
}
.check {
flex-direction: row;
align-items: center;
gap: 0.5rem;
color: var(--text);
}
.check input {
width: auto;
}
</style>

View File

@@ -1,10 +1,12 @@
<script setup>
import { onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue'
import SingerModal from '../components/SingerModal.vue'
const batches = ref([])
const selected = ref(null)
const loading = ref(false)
const openSinger = ref(null)
async function load() {
const res = await fetch('/api/suggest/batches')
@@ -42,7 +44,7 @@ onMounted(load)
@click="pick(b.id)"
>
<strong>#{{ b.id }}</strong> {{ fmt(b.createdAt) }}
<span class="dim">{{ b.type === 'tracks' ? 'låtar' : 'artister/album' }} · {{ b.count }} förslag · {{ b.trigger }}</span>
<span class="dim">{{ { tracks: 'låtar', singers: 'sångare' }[b.type] ?? 'artister/album' }} · {{ b.count }} förslag · {{ b.trigger }}</span>
</button>
</aside>
@@ -50,16 +52,20 @@ onMounted(load)
<template v-if="selected">
<p class="dim">
Batch #{{ selected.id }} ({{ fmt(selected.createdAt) }}) signaler:
{{ selected.signalCounts.bibliotek }} i biblioteket,
{{ selected.signalCounts.spelat }} nyligen spelade,
{{ selected.signalCounts.tillagt }} nyligen tillagda YT Music
{{ Object.entries(selected.signalCounts ?? {}).map(([k, v]) => `${k}: ${v}`).join(', ') }}
</p>
<div class="grid">
<MusicCard v-for="(r, i) in selected.items" :key="`${selected.id}-${r.mbid ?? i}`" :item="r" />
<MusicCard
v-for="(r, i) in selected.items"
:key="`${selected.id}-${r.mbid ?? i}`"
:item="r"
@detail="openSinger = $event"
/>
</div>
</template>
<p v-else-if="loading" class="dim">Laddar</p>
</div>
<SingerModal v-if="openSinger" :singer="openSinger" @close="openSinger = null" />
</section>
</template>