Fixed audio api endpoint, media player mostly functional

This commit is contained in:
Eclypsed
2024-04-13 00:45:35 -04:00
parent faf3794c8f
commit 2ea07ba9fe
13 changed files with 214 additions and 251 deletions

2
src/app.d.ts vendored
View File

@@ -45,7 +45,7 @@ declare global {
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
getConnectionInfo: () => Promise<ConnectionInfo>
search: (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist') => Promise<(Song | Album | Artist | Playlist)[]>
getAudioStream: (id: string) => Promise<Response>
getAudioStream: (id: string, range: string | null) => Promise<Response>
}
// These Schemas should only contain general info data that is necessary for data fetching purposes.
// They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type.

View File

@@ -1,154 +1,121 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade } from 'svelte/transition'
import { fade, slide } from 'svelte/transition'
import { currentlyPlaying } from '$lib/stores'
import { FastAverageColor } from 'fast-average-color'
// import { FastAverageColor } from 'fast-average-color'
import Slider from '$lib/components/util/slider.svelte'
export let song: Song
let playing = false,
let paused = true,
shuffle = false,
repeat = false
let bgColor = 'black',
primaryColor = 'var(--lazuli-primary)'
let volume: number,
muted = false
const rgbToHsl = (red: number, green: number, blue: number): [number, number, number] => {
;[red, green, blue].forEach((color) => {
if (!(color <= 255 && color >= 0)) throw new Error('RGB values must be between 0 and 255')
})
;(red /= 255), (green /= 255), (blue /= 255)
$: if (volume) localStorage.setItem('volume', volume.toString())
const max = Math.max(red, green, blue),
min = Math.min(red, green, blue)
let hue = 0,
saturation = 0,
lightness = (max + min) / 2
if (max !== min) {
const delta = max - min
saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)
switch (max) {
case red:
hue = (green - blue) / delta + (green < blue ? 6 : 0)
break
case green:
hue = (blue - red) / delta + 2
break
case blue:
hue = (red - green) / delta + 4
break
}
hue /= 6
}
return [hue, saturation, lightness]
const formatTime = (seconds: number): string => {
seconds = Math.floor(seconds)
const hours = Math.floor(seconds / 3600)
seconds = seconds - hours * 3600
const minutes = Math.floor(seconds / 60)
seconds = seconds - minutes * 60
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
}
const fac = new FastAverageColor()
$: fac.getColorAsync(`/api/remoteImage?url=${song.thumbnail}`, { algorithm: 'dominant' }).then((color) => {
const [red, green, blue] = color.value
const percievedLightness = Math.sqrt(0.299 * red ** 2 + 0.587 * green ** 2 + 0.114 * blue ** 2)
const redScalar = 0.547,
greenScalar = 0.766,
blueScalar = 0.338
// const [hue, staturation, lightness] = rgbToHsl(red, green, blue)
// bgColor = `hsl(${hue * 359} ${staturation * 100}% 20%)`
// primaryColor = `hsl(${hue * 359} ${staturation * 100}% 70%)`
bgColor = `rgb(${red}, ${green}, ${blue})`
primaryColor = `rgb(${red}, ${green}, ${blue})`
onMount(() => {
const storedVolume = localStorage.getItem('volume')
if (storedVolume) {
volume = Number(storedVolume)
} else {
localStorage.setItem('volume', '0.5')
volume = 0.5
}
})
let currentTime: number = 0
let duration: number = 0
let currentTimeTimestamp: HTMLSpanElement
let progressBar: Slider
let durationTimestamp: HTMLSpanElement
let seeking: boolean = false
$: if (!seeking && currentTimeTimestamp) currentTimeTimestamp.innerText = formatTime(currentTime)
$: if (!seeking && progressBar) progressBar.$set({ value: currentTime })
$: if (!seeking && durationTimestamp) durationTimestamp.innerText = formatTime(duration)
</script>
<main class="relative m-4 flex h-24 flex-grow-0 gap-4 overflow-clip rounded-xl text-white transition-colors duration-1000" style="background-color: {bgColor};">
<img src="/api/remoteImage?url={song.thumbnail}" alt="" class="aspect-square max-h-full object-cover" />
<section class="flex w-96 flex-col justify-center gap-1">
<div class="line-clamp-2">{song.name}</div>
<div class="text-sm text-neutral-400">{song.artists?.map((artist) => artist.name) || song.createdBy?.name}</div>
</section>
<section class="flex w-96 flex-col items-center justify-center gap-4">
<div class="flex items-center gap-3 text-xl">
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-8">
<i class="fa-solid fa-shuffle" style="color: {shuffle ? primaryColor : 'rgb(163, 163, 163)'};" />
</button>
<button class="aspect-square h-8">
<i class="fa-solid fa-backward-step" />
</button>
<button on:click={() => (playing = !playing)} class="grid aspect-square h-10 place-items-center rounded-full bg-white">
<i class="fa-solid {playing ? 'fa-pause' : 'fa-play'} text-black" />
</button>
<button class="aspect-square h-8">
<i class="fa-solid fa-forward-step" />
</button>
<button on:click={() => (repeat = !repeat)} class="aspect-square h-8">
<i class="fa-solid fa-repeat" style="color: {repeat ? primaryColor : 'rgb(163, 163, 163)'};" />
</button>
</div>
<Slider sliderColor={primaryColor} />
</section>
</main>
<!-- <main class="h-screen w-full">
<div class="relative h-full overflow-hidden">
<div class="absolute z-0 flex h-full w-full items-center justify-items-center bg-neutral-900">
<div class="absolute z-10 h-full w-full backdrop-blur-3xl"></div>
{#key song}
<img in:fade src={song.thumbnail} alt="" class="absolute h-full w-full object-cover brightness-50" />
{/key}
</div>
<div class="absolute grid h-full w-full grid-rows-[auto_8rem_3rem_6rem] justify-items-center p-8">
{#key song}
<img in:fade src={song.thumbnail} alt="" class="h-full min-h-[8rem] overflow-hidden rounded-xl object-contain p-2" />
{/key}
<div in:fade class="flex flex-col items-center justify-center gap-1 px-8 text-center font-notoSans">
{#key song}
<span class="text-3xl text-neutral-300">{song.name}</span>
{#if song.album?.name}
<span class="text-xl text-neutral-300">{song.album.name}</span>
{/if}
<span class="text-xl text-neutral-300">{song.artists?.map((artist) => artist.name).join(' / ') || song.createdBy?.name}</span>
{#if $currentlyPlaying}
<main transition:slide class="relative m-4 grid h-20 grid-cols-[1fr_26rem_1fr] gap-4 overflow-clip rounded-xl bg-neutral-925 text-white transition-colors duration-1000">
<section class="flex gap-3">
<div class="relative h-full w-20 min-w-20">
{#key $currentlyPlaying}
<div transition:fade={{ duration: 500 }} class="absolute h-full w-full bg-cover bg-center bg-no-repeat" style="background-image: url(/api/remoteImage?url={$currentlyPlaying.thumbnail});" />
{/key}
</div>
<input
id="progress-bar"
on:mouseup={() => (audioSource.currentTime = audioSource.duration * Number(progressBar.value))}
type="range"
value="0"
min="0"
max="1"
step="any"
class="w-[90%] cursor-pointer rounded-lg bg-gray-400"
/>
<div class="flex h-full w-11/12 justify-around overflow-hidden text-3xl text-white">
<button class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<section class="flex flex-col justify-center gap-1">
<div class="line-clamp-2 text-sm">{$currentlyPlaying.name}</div>
<div class="text-xs">{$currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || $currentlyPlaying.createdBy?.name}</div>
</section>
</section>
<section class="flex flex-col items-center justify-center gap-1">
<div class="flex items-center gap-3 text-lg">
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-8">
<i class="fa-solid fa-shuffle" />
</button>
<button class="aspect-square h-8">
<i class="fa-solid fa-backward-step" />
</button>
<button class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class={playing ? 'fa-solid fa-pause' : 'fa-solid fa-play'} />
<button on:click={() => (paused = !paused)} class="grid aspect-square h-8 place-items-center rounded-full bg-white">
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-black" />
</button>
<button on:click={() => ($currentlyPlaying = null)} class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<i class="fa-solid fa-stop" />
</button>
<button class="relative z-0 aspect-square max-h-full max-w-full overflow-hidden rounded-full">
<button class="aspect-square h-8">
<i class="fa-solid fa-forward-step" />
</button>
<button on:click={() => (repeat = !repeat)} class="aspect-square h-8">
<i class="fa-solid fa-repeat" />
</button>
</div>
</div>
</div>
<div class="no-scrollbar flex w-full flex-col items-center divide-y-[1px] divide-[#353535] overflow-y-scroll bg-neutral-900 p-4">
<div>This is where playlist items go</div>
</div>
{#key song}
<audio bind:this={audioSource} id="audio" class="hidden" src="/api/audio?id={song.id}&connection={song.connection}" />
{/key}
</main> -->
<div class="grid w-full grid-cols-[1fr_18rem_1fr] items-center justify-items-center gap-2">
<span bind:this={currentTimeTimestamp} class="w-full text-right" />
<Slider
bind:this={progressBar}
max={duration}
on:seeking={(event) => {
currentTimeTimestamp.innerText = formatTime(event.detail.value)
seeking = true
}}
on:seeked={(event) => {
currentTime = event.detail.value
seeking = false
}}
/>
<span bind:this={durationTimestamp} class="w-full text-left" />
</div>
</section>
<section class="flex items-center justify-end px-3 text-lg">
<div id="volume-slider" class="flex h-10 w-fit flex-shrink-0 flex-row-reverse items-center gap-2">
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
<i class="fa-solid {volume > 0.5 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center text-base" />
</button>
<div id="slider-wrapper" class="w-0 transition-all duration-500">
<Slider bind:value={volume} max={1} />
</div>
</div>
<button class="aspect-square h-8" on:click={() => ($currentlyPlaying = null)}>
<i class="fa-solid fa-xmark" />
</button>
</section>
<audio autoplay bind:paused bind:volume bind:currentTime bind:duration on:ended={() => ($currentlyPlaying = null)} src="/api/audio?connection={$currentlyPlaying.connection}&id={$currentlyPlaying.id}" />
</main>
{/if}
<!-- <style>
main {
display: grid;
grid-template-columns: 2fr 1fr;
<style>
#volume-slider:hover > #slider-wrapper {
width: 6rem;
}
</style> -->
#slider-wrapper:focus-within {
width: 6rem;
}
</style>

View File

@@ -3,7 +3,10 @@
let searchBar: HTMLElement, searchInput: HTMLInputElement
const triggerSearch = (searchQuery: string) => goto(`/search?query=${searchQuery}`)
const triggerSearch = (query: string) => {
const searchParams = new URLSearchParams({ query })
goto(`/search?${searchParams.toString()}`)
}
</script>
<search role="search" bind:this={searchBar} class="relative flex h-12 w-full items-center gap-2.5 justify-self-center rounded-full border-2 border-transparent px-4 py-2" style="background-color: rgba(82, 82, 82, 0.25);">

View File

@@ -1,18 +1,22 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
export let value = 0
export let sliderColor: string | undefined = undefined
export let max = 100
const dispatch = createEventDispatcher()
let sliderThumb: HTMLSpanElement, sliderTrail: HTMLSpanElement
const trackThumb = (sliderPos: number): void => {
if (sliderThumb) sliderThumb.style.left = `${sliderPos}%`
if (sliderTrail) sliderTrail.style.right = `${100 - sliderPos}%`
if (sliderThumb) sliderThumb.style.left = `${(sliderPos / max) * 100}%`
if (sliderTrail) sliderTrail.style.right = `${100 - (sliderPos / max) * 100}%`
}
$: trackThumb(value)
const handleKeyPress = (key: string) => {
if ((key === 'ArrowRight' || key === 'ArrowUp') && value < 100) return (value += 1)
if ((key === 'ArrowRight' || key === 'ArrowUp') && value < 1) return (value += 1)
if ((key === 'ArrowLeft' || key === 'ArrowDown') && value > 0) return (value -= 1)
}
</script>
@@ -20,15 +24,27 @@
<div
id="slider-track"
class="relative isolate h-1.5 w-full rounded-full bg-neutral-600"
style="--slider-color: {sliderColor || 'var(--lazuli-primary)'}"
style="--slider-color: var(--lazuli-primary)"
role="slider"
tabindex="0"
aria-valuenow={value}
aria-valuemin="0"
aria-valuemax="100"
aria-valuemax={max}
on:keydown={(event) => handleKeyPress(event.key)}
>
<input type="range" class="absolute z-10 h-1.5 w-full" step="any" min="0" max="100" bind:value tabindex="-1" aria-hidden="true" aria-disabled="true" />
<input
on:input={(event) => dispatch('seeking', { value: event.currentTarget.value })}
on:change={(event) => dispatch('seeked', { value: event.currentTarget.value })}
type="range"
class="absolute z-10 h-1.5 w-full"
step="any"
min="0"
{max}
bind:value
tabindex="-1"
aria-hidden="true"
aria-disabled="true"
/>
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1.5 rounded-full bg-white transition-colors" />
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" />
</div>

View File

@@ -1,50 +0,0 @@
<script lang="ts">
import Slider from '$lib/components/util/slider.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import { onMount } from 'svelte'
export let volume = 0
let muted = false
let storedVolume: number
const getVolume = (): number => {
const currentVolume = localStorage.getItem('volume')
if (currentVolume) return Number(currentVolume)
const defaultVolume = 100
localStorage.setItem('volume', defaultVolume.toString())
return defaultVolume
}
const setVolume = (volume: number): void => {
if (Number.isFinite(volume)) localStorage.setItem('volume', Math.round(volume).toString())
}
onMount(() => (storedVolume = getVolume()))
$: changeVolume(storedVolume)
const changeVolume = (newVolume: number) => {
if (typeof newVolume === 'number' && !isNaN(newVolume)) setVolume(newVolume)
}
$: volume = muted ? 0 : storedVolume
</script>
<div id="volume-slider" class="flex h-10 w-fit flex-shrink-0 flex-row-reverse items-center gap-2">
<IconButton halo={false} on:click={() => (muted = !muted)}>
<i slot="icon" class="fa-solid {volume > 50 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center text-base" />
</IconButton>
<div id="slider-wrapper" class="w-0 transition-all duration-500">
<Slider bind:value={storedVolume} />
</div>
</div>
<style>
#volume-slider:hover > #slider-wrapper {
width: 6rem;
}
#slider-wrapper:focus-within {
width: 6rem;
}
</style>

View File

@@ -86,7 +86,7 @@ export class Jellyfin implements Connection {
return parsedResults
}
public getAudioStream = async (id: string): Promise<Response> => {
public getAudioStream = async (id: string, range: string | null): Promise<Response> => {
const audoSearchParams = new URLSearchParams({
MaxStreamingBitrate: '2000000',
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
@@ -98,14 +98,17 @@ export class Jellyfin implements Connection {
const audioUrl = new URL(`Audio/${id}/universal?${audoSearchParams.toString()}`, this.serverUrl)
return await fetch(audioUrl, { headers: this.authHeader })
const headers = new Headers(this.authHeader)
headers.set('range', range || '0-')
return await fetch(audioUrl, { headers })
}
private parseSong = (song: JellyfinAPI.Song): Song => {
const thumbnail = song.ImageTags?.Primary
? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).href
? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).toString()
: song.AlbumPrimaryImageTag
? new URL(`Items/${song.AlbumId}/Images/Primary`, this.serverUrl).href
? new URL(`Items/${song.AlbumId}/Images/Primary`, this.serverUrl).toString()
: undefined
const artists: Song['artists'] = song.ArtistItems

View File

@@ -36,23 +36,35 @@ export class YouTubeMusic implements Connection {
}
private getAccessToken = async (): Promise<string> => {
const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => {
const MAX_TRIES = 3
let tries = 0
const refreshDetails = { client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, client_secret: YOUTUBE_API_CLIENT_SECRET, refresh_token: this.refreshToken, grant_type: 'refresh_token' }
while (tries < MAX_TRIES) {
++tries
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: JSON.stringify(refreshDetails),
}).catch((reason) => {
console.error(`Fetch to refresh endpoint failed: ${reason}`)
return null
})
if (!response || !response.ok) continue
const { access_token, expires_in } = await response.json()
const expiry = Date.now() + expires_in * 1000
return { accessToken: access_token, expiry }
}
throw new Error(`Failed to refresh access tokens for YouTube Music connection: ${this.id}`)
}
if (this.expiry < Date.now()) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: JSON.stringify({
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
client_secret: YOUTUBE_API_CLIENT_SECRET,
refresh_token: this.refreshToken,
grant_type: 'refresh_token',
}),
})
const { access_token, expires_in } = await response.json()
const newExpiry = Date.now() + expires_in * 1000
DB.updateTokens(this.id, { accessToken: access_token, refreshToken: this.refreshToken, expiry: newExpiry })
this.accessToken = access_token
this.expiry = newExpiry
const { accessToken, expiry } = await refreshTokens()
DB.updateTokens(this.id, { accessToken, refreshToken: this.refreshToken, expiry })
this.accessToken = accessToken
this.expiry = expiry
}
return this.accessToken
@@ -108,7 +120,9 @@ export class YouTubeMusic implements Connection {
if ('musicCardShelfRenderer' in section) {
parsedSearchResults.push(parseMusicCardShelfRenderer(this.id, section.musicCardShelfRenderer))
section.musicCardShelfRenderer.contents?.forEach((item) => {
parsedSearchResults.push(parseResponsiveListItemRenderer(this.id, item.musicResponsiveListItemRenderer))
if ('musicResponsiveListItemRenderer' in item) {
parsedSearchResults.push(parseResponsiveListItemRenderer(this.id, item.musicResponsiveListItemRenderer))
}
})
continue
}
@@ -156,11 +170,13 @@ export class YouTubeMusic implements Connection {
return recommendations
}
public getAudioStream = async (id: string): Promise<Response> => {
public getAudioStream = async (id: string, range: string | null): Promise<Response> => {
const videoInfo = await ytdl.getInfo(`http://www.youtube.com/watch?v=${id}`)
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
return await fetch(format.url)
const headers = new Headers({ range: range || '0-' })
return await fetch(format.url, { headers })
}
}
@@ -355,9 +371,14 @@ declare namespace InnerTube {
}
}>
}
contents?: Array<{
musicResponsiveListItemRenderer: musicResponsiveListItemRenderer
}>
contents?: Array<
| {
messageRenderer: unknown
}
| {
musicResponsiveListItemRenderer: musicResponsiveListItemRenderer
}
>
thumbnail: {
musicThumbnailRenderer: musicThumbnailRenderer
}

View File

@@ -2,14 +2,6 @@ export const generateUUID = (): string => {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
}
export const isValidURL = (url: string): boolean => {
try {
return Boolean(new URL(url))
} catch {
return false
}
}
export const getDeviceUUID = (): string => {
const existingUUID = localStorage.getItem('deviceUUID')
if (existingUUID) return existingUUID

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import SearchBar from '$lib/components/util/searchBar.svelte'
import type { LayoutData } from './$types'
import { currentlyPlaying } from '$lib/stores'
import NavTab from '$lib/components/navbar/navTab.svelte'
import PlaylistTab from '$lib/components/navbar/playlistTab.svelte'
import MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
@@ -46,8 +45,6 @@
<slot />
</section>
<section class="absolute bottom-0 z-40 grid max-h-full w-full place-items-center">
{#if $currentlyPlaying}
<MediaPlayer song={$currentlyPlaying} />
{/if}
<MediaPlayer />
</section>
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { currentlyPlaying } from '$lib/stores'
import type { PageServerData } from './$types'
export let data: PageServerData
@@ -7,7 +8,12 @@
{#if data.searchResults}
{#await data.searchResults then searchResults}
{#each searchResults as searchResult}
<div>{searchResult.name} - {searchResult.type}</div>
<button
on:click={() => {
if (searchResult.type === 'song') $currentlyPlaying = searchResult
}}
class="block bg-neutral-925">{searchResult.name} - {searchResult.type}</button
>
{/each}
{/await}
{/if}

View File

@@ -25,7 +25,7 @@
<div class="relative aspect-square h-full p-1">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" />
{#if 'profilePicture' in connectionInfo}
<img src={connectionInfo.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
<img src="/api/remoteImage?url={connectionInfo.profilePicture}" alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
{/if}
</div>
<div>

View File

@@ -1,25 +1,30 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ url }) => {
export const GET: RequestHandler = async ({ url, request }) => {
const connectionId = url.searchParams.get('connection')
const id = url.searchParams.get('id')
if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
const range = request.headers.get('range')
const connection = Connections.getConnections([connectionId])[0]
const stream = await connection.getAudioStream(id)
if (!stream.body) throw new Error(`Audio fetch did not return valid ReadableStream (Connection: ${connection.id})`)
const fetchStream = async (): Promise<Response> => {
const MAX_TRIES = 5
let tries = 0
while (tries < MAX_TRIES) {
++tries
const stream = await connection.getAudioStream(id, range).catch((reason) => {
console.error(`Audio stream fetch failed: ${reason}`)
return null
})
if (!stream || !stream.body) continue
const contentLength = stream.headers.get('Content-Length')
if (!contentLength || isNaN(Number(contentLength))) throw new Error(`Audio fetch did not return valid Content-Length header (Connection: ${connection.id})`)
return stream
}
const headers = new Headers({
'Content-Range': `bytes 0-${Number(contentLength) - 1}/${contentLength}`,
'Accept-Ranges': 'bytes',
'Content-Length': contentLength.toString(),
'Content-Type': 'audio/webm',
})
throw new Error(`Audio stream fetch to connection: ${connection.id} of id ${id} failed`)
}
return new Response(stream.body, { status: 206, headers })
return await fetchStream()
}

View File

@@ -6,18 +6,21 @@ export const GET: RequestHandler = async ({ url }) => {
const imageUrl = url.searchParams.get('url')
if (!imageUrl || !URL.canParse(imageUrl)) return new Response('Missing or invalid url parameter', { status: 400 })
const MAX_TRIES = 3
const fetchImage = async (): Promise<ArrayBuffer> => {
let tryCount = 0
while (tryCount < MAX_TRIES) {
++tryCount
try {
return await fetch(imageUrl).then((response) => response.arrayBuffer())
} catch (error) {
console.error(error)
continue
}
const MAX_TRIES = 3
let tries = 0
while (tries < MAX_TRIES) {
++tries
const response = await fetch(imageUrl).catch((reason) => {
console.error(`Image fetch to ${imageUrl} failed: ${reason}`)
return null
})
if (!response || !response.ok) continue
const contentType = response.headers.get('content-type')
if (!contentType || !contentType.startsWith('image')) throw new Error(`Url ${imageUrl} does not link to an image`)
return await response.arrayBuffer()
}
throw new Error('Exceed Max Retires')