Inbäddad mini-spelare: ▶ på korten spelar direkt i appen
All checks were successful
build-and-push / build (push) Successful in 14s
All checks were successful
build-and-push / build (push) Successful in 14s
- GET /api/play?q=: slår upp spelbart videoId via YouTube Data API (kategori musik, YOUTUBE_API_KEY i stackens .env) - MiniPlayer: fast spelare nere till höger (YouTube-embed, autoplay), ligger kvar medan man bläddrar; stängs med ✕ - MusicCard: ▶-knapp på artist/album/låt/sångare (genrer behåller sina två knappar); YT-länken heter nu 'YT ↗' - 2 nya tester, totalt 31 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
@@ -235,6 +235,33 @@ export function buildApp(config, deps = {}) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Slår upp ett spelbart video-id för mini-spelaren (YouTube Data API,
|
||||||
|
// kategori 10 = musik)
|
||||||
|
app.get('/api/play', async (req, reply) => {
|
||||||
|
const q = (req.query.q ?? '').trim()
|
||||||
|
if (!q) return reply.code(400).send({ error: 'sökterm saknas' })
|
||||||
|
const key = config.youtube?.apiKey
|
||||||
|
if (!key) return reply.code(500).send({ error: 'YOUTUBE_API_KEY saknas i stackens .env' })
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
`https://www.googleapis.com/youtube/v3/search?part=snippet&type=video` +
|
||||||
|
`&videoCategoryId=10&maxResults=1&q=${encodeURIComponent(q)}&key=${encodeURIComponent(key)}`
|
||||||
|
const res = await (deps.playFetch ?? fetch)(url)
|
||||||
|
if (!res.ok) return reply.code(502).send({ error: `YouTube svarade ${res.status}` })
|
||||||
|
const js = await res.json()
|
||||||
|
const hit = js.items?.[0]
|
||||||
|
if (!hit) return reply.code(404).send({ error: 'ingen video hittades' })
|
||||||
|
return {
|
||||||
|
videoId: hit.id.videoId,
|
||||||
|
title: hit.snippet?.title ?? q,
|
||||||
|
channel: hit.snippet?.channelTitle ?? '',
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn({ err }, 'play-uppslag misslyckades')
|
||||||
|
return reply.code(502).send({ error: 'kunde inte nå YouTube' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/suggest/items', async (req) => {
|
app.get('/api/suggest/items', async (req) => {
|
||||||
const type = SUGGEST_TYPES.includes(req.query.type) ? req.query.type : 'mix'
|
const type = SUGGEST_TYPES.includes(req.query.type) ? req.query.type : 'mix'
|
||||||
return suggest.allItems(type)
|
return suggest.allItems(type)
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export function loadConfig(env = process.env) {
|
|||||||
apiKey: env.GEMINI_API_KEY ?? '',
|
apiKey: env.GEMINI_API_KEY ?? '',
|
||||||
model: env.GEMINI_MODEL ?? 'gemini-2.5-flash',
|
model: env.GEMINI_MODEL ?? 'gemini-2.5-flash',
|
||||||
},
|
},
|
||||||
|
youtube: {
|
||||||
|
apiKey: env.YOUTUBE_API_KEY ?? '',
|
||||||
|
},
|
||||||
youtubarr: {
|
youtubarr: {
|
||||||
url: env.YOUTUBARR_URL ?? 'http://youtubarr',
|
url: env.YOUTUBARR_URL ?? 'http://youtubarr',
|
||||||
dataDir: env.YOUTUBARR_DATA ?? '/youtubarr-data',
|
dataDir: env.YOUTUBARR_DATA ?? '/youtubarr-data',
|
||||||
|
|||||||
@@ -130,6 +130,38 @@ test('POST /api/add med låt går via albumet', async () => {
|
|||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/play slår upp videoId via YouTube Data API', async () => {
|
||||||
|
const playConfig = { ...config, youtube: { apiKey: 'yt-nyckel' } }
|
||||||
|
const app = buildApp(playConfig, {
|
||||||
|
lidarr: {},
|
||||||
|
musicbrainz: {},
|
||||||
|
playFetch: async (url) => {
|
||||||
|
assert.match(url, /videoCategoryId=10/)
|
||||||
|
assert.match(url, /q=Powerwolf%20Sanctified/)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
items: [{ id: { videoId: 'abc123' }, snippet: { title: 'Sanctified with Dynamite', channelTitle: 'Powerwolf - Topic' } }],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const cookie = await loggedIn(app)
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/play?q=Powerwolf%20Sanctified', headers: { cookie } })
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
assert.equal(res.json().videoId, 'abc123')
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/play utan nyckel ger begripligt fel', async () => {
|
||||||
|
const app = buildApp({ ...config, youtube: { apiKey: '' } }, { lidarr: {}, musicbrainz: {} })
|
||||||
|
const cookie = await loggedIn(app)
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/play?q=x', headers: { cookie } })
|
||||||
|
assert.equal(res.statusCode, 500)
|
||||||
|
assert.match(res.json().error, /YOUTUBE_API_KEY/)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/add svarar already för befintlig artist', async () => {
|
test('POST /api/add svarar already för befintlig artist', async () => {
|
||||||
const app = buildApp(config, {
|
const app = buildApp(config, {
|
||||||
lidarr: { search: async () => [{ artist: { ...powerwolf, id: 12 } }] },
|
lidarr: { search: async () => [{ artist: { ...powerwolf, id: 12 } }] },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import MiniPlayer from './components/MiniPlayer.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -40,6 +41,7 @@ async function logout() {
|
|||||||
<main>
|
<main>
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
<MiniPlayer />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
64
frontend/src/components/MiniPlayer.vue
Normal file
64
frontend/src/components/MiniPlayer.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup>
|
||||||
|
import { nowPlaying } from '../player.js'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="nowPlaying" class="player">
|
||||||
|
<div class="bar">
|
||||||
|
<span class="title" :title="nowPlaying.title">▶ {{ nowPlaying.title }}</span>
|
||||||
|
<button class="close" @click="nowPlaying = null">✕</button>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
:src="`https://www.youtube.com/embed/${nowPlaying.videoId}?autoplay=1`"
|
||||||
|
title="Spelare"
|
||||||
|
frameborder="0"
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.player {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
width: min(24rem, calc(100vw - 2rem));
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.4rem 0.4rem 0.4rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,32 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { nowPlaying } from '../player.js'
|
||||||
|
|
||||||
const props = defineProps({ item: { type: Object, required: true } })
|
const props = defineProps({ item: { type: Object, required: true } })
|
||||||
const emit = defineEmits(['detail'])
|
const emit = defineEmits(['detail'])
|
||||||
const state = ref('') // '' | 'busy' | 'done' | 'fanns' | 'fel'
|
const state = ref('') // '' | 'busy' | 'done' | 'fanns' | 'fel'
|
||||||
|
const playBusy = ref(false)
|
||||||
|
|
||||||
const ytUrl = computed(() => {
|
const ytQuery = computed(() =>
|
||||||
const q =
|
props.item.kind === 'artist'
|
||||||
props.item.kind === 'artist'
|
? props.item.name
|
||||||
? props.item.name
|
: `${props.item.secondary ?? ''} ${props.item.name}`.trim(),
|
||||||
: `${props.item.secondary ?? ''} ${props.item.name}`.trim()
|
)
|
||||||
return `https://music.youtube.com/search?q=${encodeURIComponent(q)}`
|
|
||||||
})
|
const ytUrl = computed(() => `https://music.youtube.com/search?q=${encodeURIComponent(ytQuery.value)}`)
|
||||||
|
|
||||||
|
async function play() {
|
||||||
|
playBusy.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/play?q=${encodeURIComponent(ytQuery.value)}`)
|
||||||
|
const body = await res.json()
|
||||||
|
if (res.ok && body.videoId) {
|
||||||
|
nowPlaying.value = { videoId: body.videoId, title: body.title || props.item.name }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
playBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function grab() {
|
async function grab() {
|
||||||
state.value = 'busy'
|
state.value = 'busy'
|
||||||
@@ -64,7 +79,16 @@ async function grab() {
|
|||||||
{{ state === 'busy' ? 'Lägger till…' : state === 'fel' ? 'Försök igen' : '⤓ Hämta' }}
|
{{ state === 'busy' ? 'Lägger till…' : state === 'fel' ? 'Försök igen' : '⤓ Hämta' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music">▶ YT</a>
|
<button
|
||||||
|
v-if="item.kind !== 'genre'"
|
||||||
|
class="play"
|
||||||
|
:disabled="playBusy"
|
||||||
|
title="Spela direkt i mini-spelaren"
|
||||||
|
@click="play"
|
||||||
|
>
|
||||||
|
{{ playBusy ? '…' : '▶' }}
|
||||||
|
</button>
|
||||||
|
<a class="yt" :href="ytUrl" target="_blank" rel="noopener" title="Öppna i YouTube Music">YT ↗</a>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="item.kind === 'singer'" class="more" @click="emit('detail', item)">
|
<button v-if="item.kind === 'singer'" class="more" @click="emit('detail', item)">
|
||||||
Topplåtar & liknande →
|
Topplåtar & liknande →
|
||||||
@@ -184,6 +208,16 @@ async function grab() {
|
|||||||
background: rgba(255, 60, 60, 0.12);
|
background: rgba(255, 60, 60, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.play {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
background: rgba(255, 60, 60, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play:hover {
|
||||||
|
background: rgba(255, 60, 60, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.more {
|
.more {
|
||||||
background: rgba(99, 102, 241, 0.15);
|
background: rgba(99, 102, 241, 0.15);
|
||||||
color: var(--accent-hover);
|
color: var(--accent-hover);
|
||||||
|
|||||||
4
frontend/src/player.js
Normal file
4
frontend/src/player.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// Delat mini-spelar-tillstånd: {videoId, title} eller null
|
||||||
|
export const nowPlaying = ref(null)
|
||||||
Reference in New Issue
Block a user