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 Headerskopiera 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%; +}