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 { 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 } from './settings.js' import { settingsStore, suggestConfig } from './settings.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'
@@ -18,6 +18,27 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OPEN_ROUTES = new Set(['/api/health', '/api/login', '/api/session']) 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) { function passwordOk(given, expected) {
if (typeof given !== 'string' || !expected) return false if (typeof given !== 'string' || !expected) return false
const a = Buffer.from(given) const a = Buffer.from(given)
@@ -166,10 +187,7 @@ export function buildApp(config, deps = {}) {
clientSecretSet: Boolean(s.ytmusic?.clientSecret), clientSecretSet: Boolean(s.ytmusic?.clientSecret),
connected: existsSync(ytmusic.oauthPath), connected: existsSync(ytmusic.oauthPath),
}, },
suggest: { suggest: suggestConfig(s),
intervalHours: s.suggest?.intervalHours ?? 24,
trackIntervalDays: s.suggest?.trackIntervalDays ?? 7,
},
} }
}) })
@@ -188,22 +206,13 @@ export function buildApp(config, deps = {}) {
clientId: body.ytmusic?.clientId, clientId: body.ytmusic?.clientId,
clientSecret: body.ytmusic?.clientSecret, clientSecret: body.ytmusic?.clientSecret,
}, },
suggest: { suggest: sanitizeSuggest(body.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),
},
}) })
return { ok: true } return { ok: true }
}) })
app.post('/api/suggest/run', async (req, reply) => { 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 { try {
const batch = await suggest.run('manuell', type) const batch = await suggest.run('manuell', type)
return { ok: true, batchId: batch.id, count: batch.items.length } 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 () => { app.get('/api/suggest/latest', async () => {
const s = store.load() const cfg = suggestConfig(store.load())
return { return {
items: suggest.latest(10, 'mix'), items: suggest.latest(cfg.mix.count, 'mix'),
tracks: suggest.latest(10, 'tracks'), tracks: suggest.latest(cfg.tracks.count, 'tracks'),
singers: suggest.latest(cfg.singers.count, 'singers'),
lastRun: suggest.lastRun('mix'), lastRun: suggest.lastRun('mix'),
lastTrackRun: suggest.lastRun('tracks'), lastTrackRun: suggest.lastRun('tracks'),
intervalHours: s.suggest?.intervalHours ?? 24, lastSingersRun: suggest.lastRun('singers'),
trackIntervalDays: s.suggest?.trackIntervalDays ?? 7, schedule: cfg,
} }
}) })

View File

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

View File

@@ -34,6 +34,46 @@ export function settingsStore(dataDir) {
return { load, save, file } 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. // Effektiv konfig = sparade inställningar med env som fallback.
export function effectiveConfig(config, settings) { export function effectiveConfig(config, settings) {
return { return {

View File

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

View File

@@ -60,7 +60,14 @@ export function ytmusicFlow({ config, store, fetchImpl = fetch }) {
expires_in: js.expires_in, expires_in: js.expires_in,
expires_at: Math.floor(Date.now() / 1000) + (js.expires_in ?? 0), expires_at: Math.floor(Date.now() / 1000) + (js.expires_in ?? 0),
} }
try {
writeFileSync(this.oauthPath, JSON.stringify(token, null, 2)) 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' } return { status: 'klar' }
}, },
} }

View File

@@ -4,7 +4,7 @@ import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { buildApp } from '../src/app.js' 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' import { makeTesters } from '../src/testers.js'
function tmpDataDir() { 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') 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 () => { test('PUT + GET /api/settings maskerar hemligheter', async () => {
const store = settingsStore(tmpDataDir()) const store = settingsStore(tmpDataDir())
const app = buildApp(baseConfig, { lidarr: {}, musicbrainz: {}, store }) 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) 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 () => { test('run utan Gemini-nyckel ger begripligt fel', async () => {
const { engine, dataDir } = setup() const { engine, dataDir } = setup()
const store = settingsStore(dataDir) const store = settingsStore(dataDir)

View File

@@ -2,6 +2,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
const props = defineProps({ item: { type: Object, required: true } }) const props = defineProps({ item: { type: Object, required: true } })
const emit = defineEmits(['detail'])
const state = ref('') // '' | 'busy' | 'done' | 'fanns' | 'fel' const state = ref('') // '' | 'busy' | 'done' | 'fanns' | 'fel'
const ytUrl = computed(() => { const ytUrl = computed(() => {
@@ -19,7 +20,7 @@ async function grab() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
kind: props.item.kind, kind: props.item.kind === 'singer' ? 'artist' : props.item.kind,
mbid: props.item.mbid, mbid: props.item.mbid,
artistMbid: props.item.artistMbid, artistMbid: props.item.artistMbid,
albumMbid: props.item.albumMbid, albumMbid: props.item.albumMbid,
@@ -42,7 +43,7 @@ async function grab() {
<div class="poster"> <div class="poster">
<img v-if="item.poster" :src="item.poster" alt="" loading="lazy" @error="item.poster = null" /> <img v-if="item.poster" :src="item.poster" alt="" loading="lazy" @error="item.poster = null" />
<span v-else class="note"></span> <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>
<div class="meta"> <div class="meta">
<strong :title="item.name">{{ item.name }}</strong> <strong :title="item.name">{{ item.name }}</strong>
@@ -60,6 +61,9 @@ async function grab() {
</button> </button>
<a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music"> YT</a> <a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music"> YT</a>
</div> </div>
<button v-if="item.kind === 'singer'" class="more" @click="emit('detail', item)">
Topplåtar & liknande
</button>
</article> </article>
</template> </template>
@@ -174,4 +178,15 @@ async function grab() {
.yt:hover { .yt:hover {
background: rgba(255, 60, 60, 0.12); 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> </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> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue' import MusicCard from '../components/MusicCard.vue'
import SingerModal from '../components/SingerModal.vue'
const items = ref([]) const items = ref([])
const tracks = ref([]) const tracks = ref([])
const singers = ref([])
const lastRun = ref(null) const lastRun = ref(null)
const lastTrackRun = ref(null) const lastTrackRun = ref(null)
const intervalHours = ref(24) const lastSingersRun = ref(null)
const trackIntervalDays = ref(7) const schedule = ref(null)
const busy = ref({}) const busy = ref({})
const error = 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() { async function load() {
const res = await fetch('/api/suggest/latest') const res = await fetch('/api/suggest/latest')
const body = await res.json() const body = await res.json()
items.value = body.items items.value = body.items
tracks.value = body.tracks tracks.value = body.tracks
singers.value = body.singers
lastRun.value = body.lastRun lastRun.value = body.lastRun
lastTrackRun.value = body.lastTrackRun lastTrackRun.value = body.lastTrackRun
intervalHours.value = body.intervalHours lastSingersRun.value = body.lastSingersRun
trackIntervalDays.value = body.trackIntervalDays schedule.value = body.schedule
} }
async function generate(type) { async function generate(type) {
@@ -57,11 +69,7 @@ onMounted(load)
<div class="head"> <div class="head">
<h2>Upptäck</h2> <h2>Upptäck</h2>
<div class="actions"> <div class="actions">
<span class="dim"> <span class="dim">Senaste körning: {{ fmt(lastRun) }} · {{ scheduleText('mix') }}</span>
Senaste körning: {{ fmt(lastRun) }}
<template v-if="intervalHours"> · schema: var {{ intervalHours }}:e timme</template>
<template v-else> · schema: av</template>
</span>
<router-link class="link" :to="{ name: 'suggestions' }">Alla förslag </router-link> <router-link class="link" :to="{ name: 'suggestions' }">Alla förslag </router-link>
<button :disabled="busy.mix" @click="generate('mix')"> <button :disabled="busy.mix" @click="generate('mix')">
{{ busy.mix ? 'Genererar (kan ta en halv minut)' : ' Generera nya förslag' }} {{ busy.mix ? 'Genererar (kan ta en halv minut)' : ' Generera nya förslag' }}
@@ -88,11 +96,7 @@ onMounted(load)
<div class="head tracks-head"> <div class="head tracks-head">
<h2>Veckans låtar</h2> <h2>Veckans låtar</h2>
<div class="actions"> <div class="actions">
<span class="dim"> <span class="dim">Senaste körning: {{ fmt(lastTrackRun) }} · {{ scheduleText('tracks') }}</span>
Senaste körning: {{ fmt(lastTrackRun) }}
<template v-if="trackIntervalDays"> · schema: var {{ trackIntervalDays }}:e dag</template>
<template v-else> · schema: av</template>
</span>
<button :disabled="busy.tracks" @click="generate('tracks')"> <button :disabled="busy.tracks" @click="generate('tracks')">
{{ busy.tracks ? 'Genererar' : '🎵 Generera låtförslag' }} {{ busy.tracks ? 'Genererar' : '🎵 Generera låtförslag' }}
</button> </button>
@@ -104,11 +108,40 @@ onMounted(load)
</div> </div>
<div v-else class="empty small"> <div v-else class="empty small">
<p class="dim"> <p class="dim">
Inga låtförslag ännu genereras automatiskt varje vecka utifrån dina Inga låtförslag ännu genereras automatiskt utifrån dina senast
senast gillade låtar (YT Music) och mest spelade (Jellyfin), eller gillade låtar (YT Music) och mest spelade (Jellyfin), eller direkt
direkt via knappen. via knappen.
</p> </p>
</div> </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> </section>
</template> </template>

View File

@@ -5,8 +5,32 @@ 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 saved = ref({ gemini: {}, jellyfin: {}, ytmusic: {}, suggest: {} })
const intervalHours = ref(24) const suggestCfg = ref(null)
const trackIntervalDays = ref(7)
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 results = ref({}) // service -> {ok, message}
const busy = ref({}) const busy = ref({})
const jfUsers = ref([]) const jfUsers = ref([])
@@ -18,20 +42,14 @@ 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
intervalHours.value = saved.value.suggest?.intervalHours ?? 24 suggestCfg.value = saved.value.suggest
trackIntervalDays.value = saved.value.suggest?.trackIntervalDays ?? 7
}) })
async function saveInterval() { async function saveSuggest() {
await fetch('/api/settings', { await fetch('/api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ suggest: suggestCfg.value }),
suggest: {
intervalHours: Number(intervalHours.value),
trackIntervalDays: Number(trackIntervalDays.value),
},
}),
}) })
results.value = { ...results.value, suggest: { ok: true, message: 'sparat ✓' } } results.value = { ...results.value, suggest: { ok: true, message: 'sparat ✓' } }
} }
@@ -199,21 +217,50 @@ async function connectYtMusic() {
</article> </article>
<article class="card"> <article class="card">
<h3>Förslags-schema</h3> <h3>Förslag</h3>
<p class="dim"> <p class="dim">
Hur ofta nya Gemini-förslag hämtas automatiskt. Sätt 0 för att stänga Schema, antal och signalkällor per förslagstyp. Intervall 0 = av
av du kan alltid klicka "Generera nya förslag" Upptäck-sidan. du kan alltid generera manuellt från Upptäck-sidan.
</p> </p>
<label>Artist/album-förslag intervall (timmar) <template v-if="suggestCfg">
<input v-model="intervalHours" type="number" min="0" step="1" /> <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>
<label>Låtförslag ("Veckans låtar") intervall (dagar) <label>Enhet
<input v-model="trackIntervalDays" type="number" min="0" step="1" /> <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>
<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"> <div class="row">
<button @click="saveInterval">Spara</button> <button @click="saveSuggest">Spara förslags-inställningar</button>
<span v-if="results.suggest" :class="results.suggest.ok ? 'ok' : 'fail'">{{ results.suggest.message }}</span> <span v-if="results.suggest" :class="results.suggest.ok ? 'ok' : 'fail'">{{ results.suggest.message }}</span>
</div> </div>
</template>
</article> </article>
<article class="card"> <article class="card">
@@ -360,4 +407,57 @@ select {
color: var(--text-dim); color: var(--text-dim);
border: 1px solid rgba(255, 255, 255, 0.15); 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> </style>

View File

@@ -1,10 +1,12 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import MusicCard from '../components/MusicCard.vue' import MusicCard from '../components/MusicCard.vue'
import SingerModal from '../components/SingerModal.vue'
const batches = ref([]) const batches = ref([])
const selected = ref(null) const selected = ref(null)
const loading = ref(false) const loading = ref(false)
const openSinger = ref(null)
async function load() { async function load() {
const res = await fetch('/api/suggest/batches') const res = await fetch('/api/suggest/batches')
@@ -42,7 +44,7 @@ onMounted(load)
@click="pick(b.id)" @click="pick(b.id)"
> >
<strong>#{{ b.id }}</strong> {{ fmt(b.createdAt) }} <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> </button>
</aside> </aside>
@@ -50,16 +52,20 @@ onMounted(load)
<template v-if="selected"> <template v-if="selected">
<p class="dim"> <p class="dim">
Batch #{{ selected.id }} ({{ fmt(selected.createdAt) }}) signaler: Batch #{{ selected.id }} ({{ fmt(selected.createdAt) }}) signaler:
{{ selected.signalCounts.bibliotek }} i biblioteket, {{ Object.entries(selected.signalCounts ?? {}).map(([k, v]) => `${k}: ${v}`).join(', ') }}
{{ selected.signalCounts.spelat }} nyligen spelade,
{{ selected.signalCounts.tillagt }} nyligen tillagda YT Music
</p> </p>
<div class="grid"> <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> </div>
</template> </template>
<p v-else-if="loading" class="dim">Laddar</p> <p v-else-if="loading" class="dim">Laddar</p>
</div> </div>
<SingerModal v-if="openSinger" :singer="openSinger" @close="openSinger = null" />
</section> </section>
</template> </template>