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' }
},
}