Files
lyssnarr/backend/test/suggest.test.js
claude ac93b25827
Some checks failed
build-and-push / build (push) Failing after 12s
Sångare-förslag + schema/antal/källor per typ + YT Music-skrivfix
- 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
2026-08-01 02:19:58 +02:00

176 lines
6.6 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('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(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 () => {
const { engine, dataDir } = setup()
const store = settingsStore(dataDir)
store.save({ gemini: { apiKey: '' } })
await assert.rejects(() => engine.run(), /Gemini-nyckel saknas/)
})