YT Music: webbläsar-headers som primär koppling (TV-OAuth ger HTTP 400 hos youtubei)
All checks were successful
build-and-push / build (push) Successful in 19s
All checks were successful
build-and-push / build (push) Successful in 19s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
@@ -185,7 +185,8 @@ export function buildApp(config, deps = {}) {
|
|||||||
ytmusic: {
|
ytmusic: {
|
||||||
clientIdSet: Boolean(s.ytmusic?.clientId),
|
clientIdSet: Boolean(s.ytmusic?.clientId),
|
||||||
clientSecretSet: Boolean(s.ytmusic?.clientSecret),
|
clientSecretSet: Boolean(s.ytmusic?.clientSecret),
|
||||||
connected: existsSync(ytmusic.oauthPath),
|
connected: existsSync(ytmusic.browserPath) || existsSync(ytmusic.oauthPath),
|
||||||
|
browserConnected: existsSync(ytmusic.browserPath),
|
||||||
},
|
},
|
||||||
suggest: suggestConfig(s),
|
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) => {
|
app.post('/api/ytmusic/poll', async (req, reply) => {
|
||||||
const deviceCode = req.body?.deviceCode
|
const deviceCode = req.body?.deviceCode
|
||||||
if (!deviceCode) return reply.code(400).send({ error: 'deviceCode saknas' })
|
if (!deviceCode) return reply.code(400).send({ error: 'deviceCode saknas' })
|
||||||
|
|||||||
@@ -58,11 +58,15 @@ export function makeTesters({ config, store, fetchImpl = fetch }) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async ytmusic() {
|
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()
|
const s = store.load()
|
||||||
if (!s.ytmusic?.clientId || !s.ytmusic?.clientSecret) {
|
if (!s.ytmusic?.clientId || !s.ytmusic?.clientSecret) {
|
||||||
return { ok: false, message: 'Client ID/secret saknas — spara dem först' }
|
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)) {
|
if (!existsSync(oauthPath)) {
|
||||||
return { ok: false, message: 'inte kopplad än — klicka "Koppla YT Music"' }
|
return { ok: false, message: 'inte kopplad än — klicka "Koppla YT Music"' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,40 @@ export function ytmusicFlow({ config, store, fetchImpl = fetch }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
oauthPath: path.join(dataDir, 'oauth.json'),
|
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() {
|
async start() {
|
||||||
const { clientId } = creds()
|
const { clientId } = creds()
|
||||||
|
|||||||
@@ -87,3 +87,23 @@ test('poll: avslag ger felstatus', async () => {
|
|||||||
})
|
})
|
||||||
assert.deepEqual(await flow.poll('dev-123'), { status: 'error', message: 'nekad' })
|
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/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -113,6 +113,32 @@ async function pickUser() {
|
|||||||
await saveAndTest('jellyfin')
|
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() {
|
async function connectYtMusic() {
|
||||||
busy.value = { ...busy.value, ytmusic: true }
|
busy.value = { ...busy.value, ytmusic: true }
|
||||||
results.value = { ...results.value, ytmusic: null }
|
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
|
OAuth-klient av typen "TVs and Limited Input devices" i Google Cloud
|
||||||
Console (projektet youtubarr → Credentials).
|
Console (projektet youtubarr → Credentials).
|
||||||
</p>
|
</p>
|
||||||
|
<details class="sub" open>
|
||||||
|
<summary>Webbläsar-headers <span class="dim">— rekommenderas (TV-OAuth ger ofta fel hos Google)</span></summary>
|
||||||
|
<div class="subbody">
|
||||||
|
<p class="dim">
|
||||||
|
1. Öppna <a href="https://music.youtube.com" target="_blank" rel="noopener">music.youtube.com</a> inloggad →
|
||||||
|
F12 → fliken <strong>Nätverk</strong> → filtrera på <code>browse</code> → klicka ett anrop →
|
||||||
|
högerklicka under <em>Request Headers</em> → <strong>kopiera alla request-headers</strong> och klistra in här:
|
||||||
|
</p>
|
||||||
|
<textarea v-model="rawHeaders" rows="5" placeholder="POST /youtubei/v1/browse… Cookie: … User-Agent: …"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<button :disabled="busy.ytmusic || !rawHeaders.trim()" @click="saveHeaders">Spara headers</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<label>Client ID
|
<label>Client ID
|
||||||
<input v-model="ytmusic.clientId" type="text" :placeholder="saved.ytmusic.clientIdSet ? '(sparad — fyll i för att byta)' : '….apps.googleusercontent.com'" />
|
<input v-model="ytmusic.clientId" type="text" :placeholder="saved.ytmusic.clientIdSet ? '(sparad — fyll i för att byta)' : '….apps.googleusercontent.com'" />
|
||||||
</label>
|
</label>
|
||||||
@@ -460,4 +500,16 @@ legend {
|
|||||||
.check input {
|
.check input {
|
||||||
width: auto;
|
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%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user