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:
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
161
frontend/src/components/SingerModal.vue
Normal file
161
frontend/src/components/SingerModal.vue
Normal 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>
|
||||||
@@ -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 på de 10 senaste unika sångarna i
|
||||||
|
det du gillat (YT Music) och lyssnat på (Jellyfin). Klicka in på 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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" på 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 på</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>
|
||||||
|
|||||||
@@ -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 på 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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user