Some checks failed
build-and-push / build (push) Failing after 12s
- 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
176 lines
6.6 KiB
JavaScript
176 lines
6.6 KiB
JavaScript
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/)
|
||
})
|