From 07264be33829ae23bed59b2cec3fdb1b39890122 Mon Sep 17 00:00:00 2001
From: claude
Date: Sat, 1 Aug 2026 05:35:06 +0200
Subject: [PATCH] =?UTF-8?q?YT=20Music:=20webbl=C3=A4sar-headers=20som=20pr?=
=?UTF-8?q?im=C3=A4r=20koppling=20(TV-OAuth=20ger=20HTTP=20400=20hos=20you?=
=?UTF-8?q?tubei)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Alla autentiserade ytmusicapi-anrop med TV-OAuth-token får 400 'invalid
argument' — känd Google-begränsning. Ny väg: klistra in request-headers
från inloggad music.youtube.com i settings → POST /api/ytmusic/headers
bygger ytmusicapi-kompatibel browser.json i Youtubarrs datamapp.
Youtubarr-forken föredrar browser.json framför oauth.json. Testern
rapporterar header-kopplingen. 2 nya tester, totalt 33.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
---
backend/src/app.js | 9 ++++-
backend/src/testers.js | 6 +++-
backend/src/ytmusic.js | 34 +++++++++++++++++++
backend/test/ytmusic.test.js | 20 +++++++++++
frontend/src/pages/SettingsPage.vue | 52 +++++++++++++++++++++++++++++
5 files changed, 119 insertions(+), 2 deletions(-)
diff --git a/backend/src/app.js b/backend/src/app.js
index aec85a5..e2fa513 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -185,7 +185,8 @@ export function buildApp(config, deps = {}) {
ytmusic: {
clientIdSet: Boolean(s.ytmusic?.clientId),
clientSecretSet: Boolean(s.ytmusic?.clientSecret),
- connected: existsSync(ytmusic.oauthPath),
+ connected: existsSync(ytmusic.browserPath) || existsSync(ytmusic.oauthPath),
+ browserConnected: existsSync(ytmusic.browserPath),
},
suggest: suggestConfig(s),
}
@@ -298,6 +299,12 @@ export function buildApp(config, deps = {}) {
}
})
+ app.post('/api/ytmusic/headers', async (req, reply) => {
+ const out = ytmusic.saveBrowserHeaders(req.body?.raw)
+ if (out.error) return reply.code(400).send(out)
+ return out
+ })
+
app.post('/api/ytmusic/poll', async (req, reply) => {
const deviceCode = req.body?.deviceCode
if (!deviceCode) return reply.code(400).send({ error: 'deviceCode saknas' })
diff --git a/backend/src/testers.js b/backend/src/testers.js
index 88490ac..12f4080 100644
--- a/backend/src/testers.js
+++ b/backend/src/testers.js
@@ -58,11 +58,15 @@ export function makeTesters({ config, store, fetchImpl = fetch }) {
},
async ytmusic() {
+ const dataDir = config.youtubarr?.dataDir ?? '/youtubarr-data'
+ if (existsSync(path.join(dataDir, 'browser.json'))) {
+ return { ok: true, message: 'kopplad via webbläsar-headers ✓' }
+ }
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')
+ const oauthPath = path.join(dataDir, 'oauth.json')
if (!existsSync(oauthPath)) {
return { ok: false, message: 'inte kopplad än — klicka "Koppla YT Music"' }
}
diff --git a/backend/src/ytmusic.js b/backend/src/ytmusic.js
index 108403f..474a4ca 100644
--- a/backend/src/ytmusic.js
+++ b/backend/src/ytmusic.js
@@ -16,6 +16,40 @@ export function ytmusicFlow({ config, store, fetchImpl = fetch }) {
return {
oauthPath: path.join(dataDir, 'oauth.json'),
+ browserPath: path.join(dataDir, 'browser.json'),
+
+ // Bygger ytmusicapi-kompatibel browser.json av råa request-headers
+ // kopierade från en inloggad music.youtube.com-session.
+ saveBrowserHeaders(raw) {
+ const parsed = {}
+ for (const line of String(raw ?? '').split('\n')) {
+ const idx = line.indexOf(':')
+ if (idx < 1) continue
+ const key = line.slice(0, idx).trim().toLowerCase()
+ const value = line.slice(idx + 1).trim()
+ if (key && value) parsed[key] = value
+ }
+ const cookie = parsed.cookie
+ if (!cookie) return { error: 'ingen Cookie-header hittades i det du klistrade in' }
+ if (!cookie.includes('__Secure-3PAPISID')) {
+ return { error: 'cookien saknar __Secure-3PAPISID — kopiera headers från ett inloggat music.youtube.com-anrop (t.ex. "browse")' }
+ }
+ const browser = {
+ 'User-Agent': parsed['user-agent'] ?? 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
+ Accept: '*/*',
+ 'Accept-Language': parsed['accept-language'] ?? 'sv-SE,sv;q=0.8,en;q=0.5',
+ 'Content-Type': 'application/json',
+ 'X-Goog-AuthUser': parsed['x-goog-authuser'] ?? '0',
+ 'x-origin': 'https://music.youtube.com',
+ Cookie: cookie,
+ }
+ try {
+ writeFileSync(this.browserPath, JSON.stringify(browser, null, 2))
+ } catch (err) {
+ return { error: `kunde inte spara ${this.browserPath}: ${err.code ?? err.message}` }
+ }
+ return { ok: true }
+ },
async start() {
const { clientId } = creds()
diff --git a/backend/test/ytmusic.test.js b/backend/test/ytmusic.test.js
index b6f6867..d1fb972 100644
--- a/backend/test/ytmusic.test.js
+++ b/backend/test/ytmusic.test.js
@@ -87,3 +87,23 @@ test('poll: avslag ger felstatus', async () => {
})
assert.deepEqual(await flow.poll('dev-123'), { status: 'error', message: 'nekad' })
})
+
+test('saveBrowserHeaders: bygger browser.json av råa headers', () => {
+ const { dataDir, store, config } = setup()
+ const flow = ytmusicFlow({ config, store })
+ const out = flow.saveBrowserHeaders(
+ 'POST /youtubei/v1/browse HTTP/2\nUser-Agent: TestUA/1.0\nAccept-Language: sv\nX-Goog-AuthUser: 0\nCookie: VISITOR=1; __Secure-3PAPISID=hemlis; PREF=x\n',
+ )
+ assert.deepEqual(out, { ok: true })
+ const b = JSON.parse(readFileSync(path.join(dataDir, 'browser.json'), 'utf8'))
+ assert.equal(b['User-Agent'], 'TestUA/1.0')
+ assert.match(b.Cookie, /__Secure-3PAPISID/)
+ assert.equal(b['x-origin'], 'https://music.youtube.com')
+})
+
+test('saveBrowserHeaders: avvisar headers utan giltig cookie', () => {
+ const { store, config } = setup()
+ const flow = ytmusicFlow({ config, store })
+ assert.match(flow.saveBrowserHeaders('User-Agent: x').error, /Cookie/i)
+ assert.match(flow.saveBrowserHeaders('Cookie: bara=skräp').error, /__Secure-3PAPISID/)
+})
diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue
index 8afcc19..0c27fc7 100644
--- a/frontend/src/pages/SettingsPage.vue
+++ b/frontend/src/pages/SettingsPage.vue
@@ -113,6 +113,32 @@ async function pickUser() {
await saveAndTest('jellyfin')
}
+const rawHeaders = ref('')
+
+async function saveHeaders() {
+ busy.value = { ...busy.value, ytmusic: true }
+ try {
+ const res = await fetch('/api/ytmusic/headers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ raw: rawHeaders.value }),
+ })
+ const body = await res.json()
+ results.value = {
+ ...results.value,
+ ytmusic: res.ok
+ ? { ok: true, message: 'headers sparade ✓ — kopplingen är aktiv' }
+ : { ok: false, message: body.error },
+ }
+ if (res.ok) {
+ rawHeaders.value = ''
+ saved.value = await (await fetch('/api/settings')).json()
+ }
+ } finally {
+ busy.value = { ...busy.value, ytmusic: false }
+ }
+}
+
async function connectYtMusic() {
busy.value = { ...busy.value, ytmusic: true }
results.value = { ...results.value, ytmusic: null }
@@ -274,6 +300,20 @@ async function connectYtMusic() {
OAuth-klient av typen "TVs and Limited Input devices" i Google Cloud
Console (projektet youtubarr → Credentials).
+
+ Webbläsar-headers — rekommenderas (TV-OAuth ger ofta fel hos Google)
+
+
+ 1. Öppna music.youtube.com inloggad →
+ F12 → fliken Nätverk → filtrera på browse → klicka ett anrop →
+ högerklicka under Request Headers → kopiera alla request-headers och klistra in här:
+
+
+
+
+
+
+
@@ -460,4 +500,16 @@ legend {
.check input {
width: auto;
}
+
+textarea {
+ font: inherit;
+ font-size: 0.8rem;
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 0.5rem;
+ padding: 0.5rem 0.7rem;
+ resize: vertical;
+ width: 100%;
+}