Sångare-förslag + schema/antal/källor per typ + YT Music-skrivfix
Some checks failed
build-and-push / build (push) Failing after 12s
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user