YT Music-koppling i settings: Googles device-flöde inbyggt
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:
2026-08-01 01:32:48 +02:00
parent aea7ec3ca7
commit 81a824f005
6 changed files with 337 additions and 2 deletions

View File

@@ -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 }
}
}
</script>
<template>
@@ -124,6 +180,37 @@ async function pickUser() {
</div>
</article>
<article class="card">
<h3>
YouTube Music
<span v-if="saved.ytmusic.connected" class="badge">kopplad </span>
<span v-else-if="saved.ytmusic.clientIdSet" class="badge warn">ej kopplad</span>
</h3>
<p class="dim">
Ger åtkomst till dina gillade låtar och privata spellistor. Kräver en
OAuth-klient av typen "TVs and Limited Input devices" i Google Cloud
Console (projektet youtubarr Credentials).
</p>
<label>Client ID
<input v-model="ytmusic.clientId" type="text" :placeholder="saved.ytmusic.clientIdSet ? '(sparad — fyll i för att byta)' : '….apps.googleusercontent.com'" />
</label>
<label>Client secret
<input v-model="ytmusic.clientSecret" type="password" :placeholder="saved.ytmusic.clientSecretSet ? '•••••• (sparad — fyll i för att byta)' : 'GOCSPX-…'" />
</label>
<div v-if="device" class="device">
Öppna <a :href="device.url" target="_blank" rel="noopener">{{ device.url }}</a>
och ange koden: <strong class="code">{{ device.userCode }}</strong>
<span class="dim">(väntar godkännande)</span>
</div>
<div class="row">
<button :disabled="busy.ytmusic" @click="connectYtMusic">
{{ busy.ytmusic ? 'Väntar Google' : 'Spara & koppla YT Music' }}
</button>
<button class="ghostbtn" :disabled="busy.ytmusic" @click="test('ytmusic')">Testa kopplingen</button>
<span v-if="results.ytmusic" :class="results.ytmusic.ok ? 'ok' : 'fail'">{{ results.ytmusic.message }}</span>
</div>
</article>
<article class="card">
<h3>Status fasta kopplingar</h3>
<p class="dim">Lidarr och Youtubarr konfigureras i stackens .env Pi5:an; här kan du testa att de svarar.</p>
@@ -214,4 +301,27 @@ select {
.fail {
color: var(--danger);
}
.badge.warn {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
}
.device {
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(99, 102, 241, 0.4);
border-radius: 0.5rem;
padding: 0.7rem 0.9rem;
}
.code {
font-size: 1.2rem;
letter-spacing: 0.15em;
}
.ghostbtn {
background: transparent;
color: var(--text-dim);
border: 1px solid rgba(255, 255, 255, 0.15);
}
</style>