Files
lyssnarr/backend/test/suggest.test.js
claude 94065f6c11
All checks were successful
build-and-push / build (push) Successful in 44s
Förslagsmotorn: Generera-knapp, schema, diskcache och bläddringssida
- 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
2026-08-01 01:43:07 +02:00

118 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/)
})