All checks were successful
build-and-push / build (push) Successful in 44s
- suggest.js: samlar signaler (Lidarr-bibliotek, Jellyfin senast spelat, Youtubarr-DB senast tillagt/gillat via better-sqlite3 ro), bygger svensk Gemini-prompt (JSON-svar), berikar förslagen med MBID/omslag via Lidarrs metadata-API, cachar batcher i /data/suggestions.json - API: POST /api/suggest/run, GET latest (10 senaste till Upptäck), GET batches + batches/:id (bläddring) - Schemaläggare i server.js: kollar var 10:e minut mot inställningen suggest.intervalHours (default 24, 0 = av) — ställs in under ⚙ - Upptäck: senaste 10 som kort med motivering + '✨ Generera nya förslag'; ny Förslag-sida bläddrar alla cachade batcher; delat MusicCard-kort (sök + förslag) med Hämta-knapp - Dockerfile: byggverktyg för better-sqlite3; 4 nya tester, totalt 25 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
118 lines
4.3 KiB
JavaScript
118 lines
4.3 KiB
JavaScript
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { existsSync, mkdtempSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import path from 'node:path'
|
||
import Database from 'better-sqlite3'
|
||
import { settingsStore } from '../src/settings.js'
|
||
import { suggestEngine } from '../src/suggest.js'
|
||
|
||
function makeYoutubarrDb(dir) {
|
||
const p = path.join(dir, 'db.sqlite3')
|
||
const db = new Database(p)
|
||
db.exec(`
|
||
CREATE TABLE youtubarr_playlist (id INTEGER PRIMARY KEY, title TEXT);
|
||
CREATE TABLE youtubarr_trackitem (
|
||
id INTEGER PRIMARY KEY, playlist_id INTEGER, title TEXT,
|
||
artist_name_guess TEXT, blacklisted INTEGER DEFAULT 0
|
||
);
|
||
INSERT INTO youtubarr_playlist VALUES (1, 'lefu-Musick');
|
||
INSERT INTO youtubarr_trackitem VALUES (1, 1, 'Amaranth', 'Nightwish', 0);
|
||
INSERT INTO youtubarr_trackitem VALUES (2, 1, 'Svart', 'Hemligt Band', 1);
|
||
`)
|
||
db.close()
|
||
return p
|
||
}
|
||
|
||
function setup({ geminiResponse } = {}) {
|
||
const dataDir = mkdtempSync(path.join(tmpdir(), 'lyssnarr-sug-'))
|
||
const ytDir = mkdtempSync(path.join(tmpdir(), 'lyssnarr-ytdb-'))
|
||
const store = settingsStore(dataDir)
|
||
store.save({ gemini: { apiKey: 'g-nyckel' }, jellyfin: { apiKey: 'jf', userId: 'u1' } })
|
||
const config = {
|
||
dataDir,
|
||
gemini: { apiKey: '', model: 'gemini-2.5-flash' },
|
||
jellyfin: { url: 'http://jellyfin:8096', apiKey: '' },
|
||
youtubarr: { dbPath: makeYoutubarrDb(ytDir), dataDir: ytDir },
|
||
}
|
||
const lidarr = {
|
||
artists: async () => [{ artistName: 'Powerwolf' }],
|
||
search: async () => [
|
||
{
|
||
artist: {
|
||
foreignArtistId: 'mbid-x',
|
||
artistName: 'Sabaton',
|
||
images: [{ coverType: 'poster', remoteUrl: 'http://img/s.jpg' }],
|
||
},
|
||
},
|
||
],
|
||
}
|
||
const calls = []
|
||
const fetchImpl = async (url, opts) => {
|
||
calls.push(url)
|
||
if (url.includes('generativelanguage')) {
|
||
const body = JSON.parse(opts.body)
|
||
fetchImpl.prompt = body.contents[0].parts[0].text
|
||
return {
|
||
ok: true,
|
||
json: async () => ({
|
||
candidates: [{ content: { parts: [{ text: geminiResponse ?? JSON.stringify([
|
||
{ type: 'artist', name: 'Sabaton', motivation: 'Powermetal som Powerwolf.' },
|
||
]) }] } }],
|
||
}),
|
||
}
|
||
}
|
||
if (url.includes('/Users/u1/Items')) {
|
||
return { ok: true, json: async () => ({ Items: [{ Name: 'Zenit', Artists: ['1000mods'] }] }) }
|
||
}
|
||
throw new Error('oväntad url: ' + url)
|
||
}
|
||
const engine = suggestEngine({ config, store, lidarr, fetchImpl })
|
||
return { engine, fetchImpl, dataDir }
|
||
}
|
||
|
||
test('run: samlar signaler, frågar Gemini, berikar och cachar på disk', async () => {
|
||
const { engine, fetchImpl, dataDir } = setup()
|
||
const batch = await engine.run('manuell')
|
||
|
||
assert.match(fetchImpl.prompt, /Powerwolf/, 'biblioteket ska in i prompten')
|
||
assert.match(fetchImpl.prompt, /1000mods – Zenit/, 'jellyfin-historiken ska in i prompten')
|
||
assert.match(fetchImpl.prompt, /Nightwish – Amaranth \[lefu-Musick\]/, 'YT-tillägg ska in i prompten')
|
||
assert.ok(!fetchImpl.prompt.includes('Hemligt Band'), 'svartlistade låtar ska inte med')
|
||
|
||
assert.equal(batch.items.length, 1)
|
||
const item = batch.items[0]
|
||
assert.equal(item.name, 'Sabaton')
|
||
assert.equal(item.mbid, 'mbid-x')
|
||
assert.equal(item.poster, 'http://img/s.jpg')
|
||
assert.equal(item.motivation, 'Powermetal som Powerwolf.')
|
||
|
||
assert.ok(existsSync(path.join(dataDir, 'suggestions.json')))
|
||
assert.equal(engine.latest(10).length, 1)
|
||
assert.equal(engine.lastRun(), batch.createdAt)
|
||
})
|
||
|
||
test('run: batch-id räknas upp och latest tar nyaste först', async () => {
|
||
const { engine } = setup()
|
||
const b1 = await engine.run()
|
||
const b2 = await engine.run()
|
||
assert.equal(b2.id, b1.id + 1)
|
||
assert.equal(engine.loadBatches().length, 2)
|
||
assert.equal(engine.loadBatches()[0].id, b2.id)
|
||
})
|
||
|
||
test('run: markdown-staket runt JSON hanteras', async () => {
|
||
const { engine } = setup({
|
||
geminiResponse: '```json\n[{"type":"artist","name":"Ghost","motivation":"x"}]\n```',
|
||
})
|
||
const batch = await engine.run()
|
||
assert.equal(batch.items[0].name, 'Ghost')
|
||
})
|
||
|
||
test('run utan Gemini-nyckel ger begripligt fel', async () => {
|
||
const { engine, dataDir } = setup()
|
||
const store = settingsStore(dataDir)
|
||
store.save({ gemini: { apiKey: '' } })
|
||
await assert.rejects(() => engine.run(), /Gemini-nyckel saknas/)
|
||
})
|