Files
lyssnarr/backend/test/suggest.test.js
claude 4e89bd1986
All checks were successful
build-and-push / build (push) Successful in 12s
Förslag-sidan: typ-knappar i vänsterspalten, alla fynd nyast först
Tre knappar (Artister & album / Låtar / Genrer) ersätter batch-listan;
huvudspalten visar alla cachade fynd av vald typ i tidsordning med
nyast först, grupperade per körning med datum-avdelare. Nytt API
GET /api/suggest/items?type=X (plattar batcher med batch-metadata).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
2026-08-01 03:07:54 +02:00

193 lines
7.4 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, playlist_id TEXT, 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, 'PLC0x', 'lefu-Musick');
INSERT INTO youtubarr_playlist VALUES (2, 'LM', 'Liked Music');
INSERT INTO youtubarr_trackitem VALUES (1, 1, 'Amaranth', 'Nightwish', 0);
INSERT INTO youtubarr_trackitem VALUES (2, 1, 'Svart', 'Hemligt Band', 1);
INSERT INTO youtubarr_trackitem VALUES (3, 2, 'Gimme! Gimme! Gimme!', 'ABBA', 0);
`)
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('allItems: plattar batcher nyast först med batch-metadata', async () => {
const { engine } = setup()
const b1 = await engine.run()
const b2 = await engine.run()
const all = engine.allItems('mix')
assert.equal(all.length, 2)
assert.equal(all[0].batchId, b2.id, 'nyaste batchens fynd först')
assert.equal(all[1].batchId, b1.id)
assert.equal(all[0].batchCreatedAt, b2.createdAt)
assert.equal(engine.allItems('tracks').length, 0)
})
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(tracks): gillade + mest spelade in i prompten, egen batch-typ', async () => {
const { engine, fetchImpl } = setup({
geminiResponse: JSON.stringify([
{ name: 'Dancing Queen', artist: 'ABBA', motivation: 'Klassiker.' },
]),
})
const batch = await engine.run('manuell', 'tracks')
assert.match(fetchImpl.prompt, /ENSKILDA LÅTAR/)
assert.match(fetchImpl.prompt, /ABBA Gimme! Gimme! Gimme!/, 'Liked Music-låtar ska in')
assert.match(fetchImpl.prompt, /1000mods Zenit/, 'mest spelade ska in')
assert.equal(batch.type, 'tracks')
assert.equal(batch.items[0].kind, 'track')
assert.equal(batch.items[0].secondary, 'ABBA')
assert.equal(batch.items[0].artistMbid, 'mbid-x', 'artist-mbid berikas för Hämta')
assert.equal(engine.latest(10, 'tracks').length, 1)
assert.equal(engine.latest(10, 'mix').length, 0, 'låt-batcher ska inte blandas in i mixen')
assert.equal(engine.lastRun('tracks'), batch.createdAt)
assert.equal(engine.lastRun('mix'), null)
})
test('run(genres): artister i prompt, filtrering mot bibliotek + gillat', async () => {
const { engine, fetchImpl } = setup({
geminiResponse: JSON.stringify([
{
name: 'Symphonic metal',
motivation: 'Storslaget och episkt.',
topArtists: ['Powerwolf', 'Epica'],
topTracks: [
{ name: 'Gimme! Gimme! Gimme!', artist: 'ABBA' },
{ name: 'Storm', artist: 'Floor Jansen' },
],
},
]),
})
const batch = await engine.run('manuell', 'genres')
assert.match(fetchImpl.prompt, /GENRER/)
assert.match(fetchImpl.prompt, /ABBA/, 'artister ur gillat ska in i prompten')
assert.match(fetchImpl.prompt, /1000mods/, 'artister ur spelat ska in i prompten')
assert.equal(batch.type, 'genres')
const g = batch.items[0]
assert.equal(g.kind, 'genre')
assert.equal(g.topTracks.length, 1, 'gillad låt (Gimme! Gimme! Gimme!) ska filtreras bort')
assert.equal(g.topTracks[0].name, 'Storm')
assert.deepEqual(
g.topArtists.map((a) => a.name),
['Epica'],
'Powerwolf finns i biblioteket och ska filtreras bort',
)
assert.equal(g.topArtists[0].mbid, 'mbid-x')
assert.equal(g.topArtists[0].kind, 'artist', 'toppartister ska vara färdiga kort')
assert.equal(g.topTracks[0].kind, 'track')
assert.equal(g.topTracks[0].artistMbid, 'mbid-x', 'låtar i genren berikas med artist-mbid')
assert.equal(g.topTracks[0].poster, 'http://img/s.jpg', 'låtar i genren får omslag')
assert.equal(g.poster, 'http://img/s.jpg', 'genrekortet lånar första toppartistens omslag')
})
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/)
})