diff --git a/backend/src/app.js b/backend/src/app.js index 48976f0..7a702b3 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -11,6 +11,7 @@ import { mbClient } from './musicbrainz.js' import { mapLidarrSearch } from './mapping.js' import { settingsStore } from './settings.js' import { makeTesters } from './testers.js' +import { ytmusicFlow } from './ytmusic.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -28,6 +29,7 @@ export function buildApp(config, deps = {}) { const mb = deps.musicbrainz ?? mbClient() const store = deps.store ?? settingsStore(config.dataDir ?? '/data') const testers = deps.testers ?? makeTesters({ config, store }) + const ytmusic = deps.ytmusic ?? ytmusicFlow({ config, store }) const app = Fastify({ logger: true, trustProxy: true }) app.register(fastifyCookie) @@ -157,6 +159,11 @@ export function buildApp(config, deps = {}) { userId: s.jellyfin?.userId ?? '', userName: s.jellyfin?.userName ?? '', }, + ytmusic: { + clientIdSet: Boolean(s.ytmusic?.clientId), + clientSecretSet: Boolean(s.ytmusic?.clientSecret), + connected: existsSync(ytmusic.oauthPath), + }, } }) @@ -171,10 +178,36 @@ export function buildApp(config, deps = {}) { userId: body.jellyfin?.userId, userName: body.jellyfin?.userName, }, + ytmusic: { + clientId: body.ytmusic?.clientId, + clientSecret: body.ytmusic?.clientSecret, + }, }) return { ok: true } }) + app.post('/api/ytmusic/start', async (req, reply) => { + try { + const out = await ytmusic.start() + if (out.error) return reply.code(400).send(out) + return out + } catch (err) { + req.log.error({ err }, 'device-flödet kunde inte startas') + return reply.code(502).send({ error: 'kunde inte nå Google' }) + } + }) + + app.post('/api/ytmusic/poll', async (req, reply) => { + const deviceCode = req.body?.deviceCode + if (!deviceCode) return reply.code(400).send({ error: 'deviceCode saknas' }) + try { + return await ytmusic.poll(deviceCode) + } catch (err) { + req.log.error({ err }, 'device-poll misslyckades') + return reply.code(502).send({ error: 'kunde inte nå Google' }) + } + }) + app.post('/api/settings/test', async (req, reply) => { const service = req.body?.service if (!testers[service]) return reply.code(400).send({ error: 'okänd tjänst' }) diff --git a/backend/src/config.js b/backend/src/config.js index b9ff290..74d3d63 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -22,6 +22,7 @@ export function loadConfig(env = process.env) { }, youtubarr: { url: env.YOUTUBARR_URL ?? 'http://youtubarr', + dataDir: env.YOUTUBARR_DATA ?? '/youtubarr-data', dbPath: env.YOUTUBARR_DB ?? '/youtubarr-data/db.sqlite3', }, } diff --git a/backend/src/testers.js b/backend/src/testers.js index 4e2fd89..88490ac 100644 --- a/backend/src/testers.js +++ b/backend/src/testers.js @@ -1,4 +1,5 @@ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' import { effectiveConfig } from './settings.js' // Live-tester för varje integration — används av "Spara & testa" på @@ -56,6 +57,40 @@ export function makeTesters({ config, store, fetchImpl = fetch }) { return { ok: true, message: `ok — Lidarr ${js.version}` } }, + async ytmusic() { + const s = store.load() + if (!s.ytmusic?.clientId || !s.ytmusic?.clientSecret) { + return { ok: false, message: 'Client ID/secret saknas — spara dem först' } + } + const oauthPath = path.join(config.youtubarr?.dataDir ?? '/youtubarr-data', 'oauth.json') + if (!existsSync(oauthPath)) { + return { ok: false, message: 'inte kopplad än — klicka "Koppla YT Music"' } + } + let tok + try { + tok = JSON.parse(readFileSync(oauthPath, 'utf8')) + } catch { + return { ok: false, message: 'oauth.json går inte att läsa' } + } + let res + try { + res = await fetchImpl('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: s.ytmusic.clientId, + client_secret: s.ytmusic.clientSecret, + refresh_token: tok.refresh_token, + grant_type: 'refresh_token', + }).toString(), + }) + } catch { + return { ok: false, message: 'kunde inte nå Google (nätfel)' } + } + if (!res.ok) return { ok: false, message: `Google avvisade token-förnyelsen (${res.status}) — koppla om` } + return { ok: true, message: 'ok — YT Music-kopplingen fungerar' } + }, + async youtubarr() { let res try { diff --git a/backend/src/ytmusic.js b/backend/src/ytmusic.js new file mode 100644 index 0000000..052865b --- /dev/null +++ b/backend/src/ytmusic.js @@ -0,0 +1,67 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' + +const SCOPE = 'https://www.googleapis.com/auth/youtube' +const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' } + +// Googles device-flöde för YT Music (ytmusicapi-kompatibel oauth.json). +// Klienten (settings-sidan) visar kod + länk och pollar tills godkänt. +export function ytmusicFlow({ config, store, fetchImpl = fetch }) { + const dataDir = config.youtubarr?.dataDir ?? '/youtubarr-data' + + function creds() { + const s = store.load() + return { clientId: s.ytmusic?.clientId ?? '', clientSecret: s.ytmusic?.clientSecret ?? '' } + } + + return { + oauthPath: path.join(dataDir, 'oauth.json'), + + async start() { + const { clientId } = creds() + if (!clientId) return { error: 'spara Client ID först' } + const res = await fetchImpl('https://oauth2.googleapis.com/device/code', { + method: 'POST', + headers: FORM, + body: new URLSearchParams({ client_id: clientId, scope: SCOPE }).toString(), + }) + const js = await res.json() + if (!res.ok) return { error: js.error_description ?? js.error ?? 'okänt fel från Google' } + return { + deviceCode: js.device_code, + userCode: js.user_code, + url: js.verification_url ?? 'https://www.google.com/device', + interval: js.interval ?? 5, + expiresIn: js.expires_in, + } + }, + + async poll(deviceCode) { + const { clientId, clientSecret } = creds() + if (!clientId || !clientSecret) return { status: 'error', message: 'client id/secret saknas' } + const res = await fetchImpl('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: FORM, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + device_code: deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }).toString(), + }) + const js = await res.json() + if (js.error === 'authorization_pending' || js.error === 'slow_down') return { status: 'pending' } + if (js.error) return { status: 'error', message: js.error_description ?? js.error } + const token = { + scope: js.scope ?? SCOPE, + token_type: js.token_type ?? 'Bearer', + access_token: js.access_token, + refresh_token: js.refresh_token, + expires_in: js.expires_in, + expires_at: Math.floor(Date.now() / 1000) + (js.expires_in ?? 0), + } + writeFileSync(this.oauthPath, JSON.stringify(token, null, 2)) + return { status: 'klar' } + }, + } +} diff --git a/backend/test/ytmusic.test.js b/backend/test/ytmusic.test.js new file mode 100644 index 0000000..b6f6867 --- /dev/null +++ b/backend/test/ytmusic.test.js @@ -0,0 +1,89 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { settingsStore } from '../src/settings.js' +import { ytmusicFlow } from '../src/ytmusic.js' + +function setup() { + const dataDir = mkdtempSync(path.join(tmpdir(), 'lyssnarr-yt-')) + const store = settingsStore(dataDir) + store.save({ ytmusic: { clientId: 'klient-id', clientSecret: 'klient-hemlis' } }) + const config = { youtubarr: { dataDir } } + return { dataDir, store, config } +} + +test('start returnerar kod och länk från Google', async () => { + const { store, config } = setup() + const flow = ytmusicFlow({ + config, + store, + fetchImpl: async (url, opts) => { + assert.match(url, /device\/code/) + assert.match(opts.body, /client_id=klient-id/) + return { + ok: true, + json: async () => ({ + device_code: 'dev-123', + user_code: 'ABCD-EFGH', + verification_url: 'https://www.google.com/device', + interval: 5, + expires_in: 1800, + }), + } + }, + }) + const out = await flow.start() + assert.equal(out.userCode, 'ABCD-EFGH') + assert.equal(out.deviceCode, 'dev-123') +}) + +test('start utan client id ger fel', async () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'lyssnarr-yt-')) + const flow = ytmusicFlow({ config: { youtubarr: { dataDir } }, store: settingsStore(dataDir) }) + const out = await flow.start() + assert.match(out.error, /Client ID/i) +}) + +test('poll: pending -> klar skriver ytmusicapi-kompatibel oauth.json', async () => { + const { dataDir, store, config } = setup() + let calls = 0 + const flow = ytmusicFlow({ + config, + store, + fetchImpl: async () => { + calls++ + if (calls === 1) return { ok: false, json: async () => ({ error: 'authorization_pending' }) } + return { + ok: true, + json: async () => ({ + access_token: 'acc', + refresh_token: 'ref', + expires_in: 3600, + scope: 'https://www.googleapis.com/auth/youtube', + token_type: 'Bearer', + }), + } + }, + }) + assert.deepEqual(await flow.poll('dev-123'), { status: 'pending' }) + assert.deepEqual(await flow.poll('dev-123'), { status: 'klar' }) + const p = path.join(dataDir, 'oauth.json') + assert.ok(existsSync(p)) + const tok = JSON.parse(readFileSync(p, 'utf8')) + assert.equal(tok.access_token, 'acc') + assert.equal(tok.refresh_token, 'ref') + assert.equal(tok.token_type, 'Bearer') + assert.ok(tok.expires_at > Math.floor(Date.now() / 1000)) +}) + +test('poll: avslag ger felstatus', async () => { + const { store, config } = setup() + const flow = ytmusicFlow({ + config, + store, + fetchImpl: async () => ({ ok: false, json: async () => ({ error: 'access_denied', error_description: 'nekad' }) }), + }) + assert.deepEqual(await flow.poll('dev-123'), { status: 'error', message: 'nekad' }) +}) diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue index 92b694c..3539f5b 100644 --- a/frontend/src/pages/SettingsPage.vue +++ b/frontend/src/pages/SettingsPage.vue @@ -3,10 +3,12 @@ import { onMounted, ref } from 'vue' const gemini = ref({ apiKey: '', model: '' }) const jellyfin = ref({ url: '', apiKey: '', userId: '' }) -const saved = ref({ gemini: {}, jellyfin: {} }) +const ytmusic = ref({ clientId: '', clientSecret: '' }) +const saved = ref({ gemini: {}, jellyfin: {}, ytmusic: {} }) const results = ref({}) // service -> {ok, message} const busy = ref({}) const jfUsers = ref([]) +const device = ref(null) // {url, userCode} under pågående koppling onMounted(async () => { const res = await fetch('/api/settings') @@ -74,6 +76,60 @@ async function test(service) { async function pickUser() { await saveAndTest('jellyfin') } + +async function connectYtMusic() { + busy.value = { ...busy.value, ytmusic: true } + results.value = { ...results.value, ytmusic: null } + device.value = null + try { + await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ytmusic: { + clientId: ytmusic.value.clientId || null, + clientSecret: ytmusic.value.clientSecret || null, + }, + }), + }) + const startRes = await fetch('/api/ytmusic/start', { method: 'POST' }) + const start = await startRes.json() + if (!startRes.ok) { + results.value = { ...results.value, ytmusic: { ok: false, message: start.error } } + return + } + device.value = { url: start.url, userCode: start.userCode } + const deadline = Date.now() + (start.expiresIn ?? 600) * 1000 + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, (start.interval ?? 5) * 1000)) + const pollRes = await fetch('/api/ytmusic/poll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceCode: start.deviceCode }), + }) + const poll = await pollRes.json() + if (poll.status === 'klar') { + device.value = null + ytmusic.value = { clientId: '', clientSecret: '' } + results.value = { ...results.value, ytmusic: { ok: true, message: 'kopplad ✓ — Liked Music kan nu läsas' } } + saved.value = await (await fetch('/api/settings')).json() + return + } + if (poll.status === 'error') { + device.value = null + results.value = { ...results.value, ytmusic: { ok: false, message: poll.message } } + return + } + } + device.value = null + results.value = { ...results.value, ytmusic: { ok: false, message: 'koden hann gå ut — prova igen' } } + } catch { + device.value = null + results.value = { ...results.value, ytmusic: { ok: false, message: 'något gick fel' } } + } finally { + busy.value = { ...busy.value, ytmusic: false } + } +}