YT Music-koppling i settings: Googles device-flöde inbyggt
All checks were successful
build-and-push / build (push) Successful in 16s
All checks were successful
build-and-push / build (push) Successful in 16s
- POST /api/ytmusic/start + /poll: kör device-flödet mot Google och skriver ytmusicapi-kompatibel oauth.json till Youtubarrs datamapp (mounten byts till rw i stacken) - Settings-sektion: Client ID/secret, 'Spara & koppla' visar kod + länk och pollar tills godkänt; 'Testa kopplingen' verifierar med en token-förnyelse mot Google - 4 nya tester, totalt 21 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
67
backend/src/ytmusic.js
Normal file
67
backend/src/ytmusic.js
Normal file
@@ -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' }
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user