Files
lyssnarr/backend/test/suggest.test.js
claude 75f3788c1f
All checks were successful
build-and-push / build (push) Successful in 13s
YT Music-knapp på korten + Veckans låtar (egen låtförslags-batch)
- MusicCard: ▶ YT-knapp öppnar music.youtube.com-sökning på artist/låt/album
- Låtförslag: egen batch-typ 'tracks' — signaler: 10 senast gillade
  (Liked Music-listan i Youtubarr-DB, fallback senaste tillägg) + 10 mest
  spelade i Jellyfin (SortBy=PlayCount); egen Gemini-prompt för enskilda
  låtar; berikas med artist-MBID så Hämta lägger in artisten i Lidarr
- Upptäck: egen 'Veckans låtar'-ruta med senaste 10 + generera-knapp
- Schema: trackIntervalDays (default 7, 0 = av) i inställningarna,
  schemaläggaren kör båda typerna oberoende
- Förslag-sidan märker batcher med typ; 26 tester gröna

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

143 lines
5.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('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 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/)
})