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