Dropped ytdl, YT audio now fetched with Android Client. Began work on YT Premium support

This commit is contained in:
Eclypsed
2024-05-28 00:46:34 -04:00
parent fec4bba61e
commit 11497f8b91
28 changed files with 932 additions and 393 deletions

45
package-lock.json generated
View File

@@ -19,9 +19,7 @@
"musicbrainz-api": "^0.15.0", "musicbrainz-api": "^0.15.0",
"node-vibrant": "^3.2.1-alpha.1", "node-vibrant": "^3.2.1-alpha.1",
"pocketbase": "^0.21.1", "pocketbase": "^0.21.1",
"type-fest": "^4.12.0", "type-fest": "^4.12.0"
"ytdl-core": "^4.11.5",
"zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
@@ -2885,18 +2883,6 @@
"node": "14 || >=16.14" "node": "14 || >=16.14"
} }
}, },
"node_modules/m3u8stream": {
"version": "0.8.6",
"resolved": "https://registry.npmjs.org/m3u8stream/-/m3u8stream-0.8.6.tgz",
"integrity": "sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA==",
"dependencies": {
"miniget": "^4.2.2",
"sax": "^1.2.4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.5", "version": "0.30.5",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
@@ -2995,14 +2981,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/miniget": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/miniget/-/miniget-4.2.3.tgz",
"integrity": "sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA==",
"engines": {
"node": ">=12"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -5130,27 +5108,6 @@
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
},
"node_modules/ytdl-core": {
"version": "4.11.5",
"resolved": "https://registry.npmjs.org/ytdl-core/-/ytdl-core-4.11.5.tgz",
"integrity": "sha512-27LwsW4n4nyNviRCO1hmr8Wr5J1wLLMawHCQvH8Fk0hiRqrxuIu028WzbJetiYH28K8XDbeinYW4/wcHQD1EXA==",
"dependencies": {
"m3u8stream": "^0.8.6",
"miniget": "^4.2.2",
"sax": "^1.1.3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/zod": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View File

@@ -38,8 +38,6 @@
"musicbrainz-api": "^0.15.0", "musicbrainz-api": "^0.15.0",
"node-vibrant": "^3.2.1-alpha.1", "node-vibrant": "^3.2.1-alpha.1",
"pocketbase": "^0.21.1", "pocketbase": "^0.21.1",
"type-fest": "^4.12.0", "type-fest": "^4.12.0"
"ytdl-core": "^4.11.5",
"zod": "^3.22.4"
} }
} }

54
src/app.d.ts vendored
View File

@@ -40,12 +40,52 @@ declare global {
profilePicture?: string profilePicture?: string
}) })
type SearchFilterMap<Filter> =
Filter extends 'song' ? Song :
Filter extends 'album' ? Album :
Filter extends 'artist' ? Artist :
Filter extends 'playlist' ? Playlist :
Filter extends undefined ? Song | Album | Artist | Playlist :
never
interface Connection { interface Connection {
public id: string public id: string
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
getConnectionInfo: () => Promise<ConnectionInfo> getConnectionInfo: () => Promise<ConnectionInfo>
search: (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist') => Promise<(Song | Album | Artist | Playlist)[]> getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
getAudioStream: (id: string, range: string | null) => Promise<Response> search: <T extends 'song' | 'album' | 'artist' | 'playlist'>(searchTerm: string, filter?: T) => Promise<SearchFilterMap<T>[]>
/**
* @param id The id of the requested song
* @param headers The request headers sent by the Lazuli client that need to be relayed to the connection's request to the server (e.g. 'range').
* @returns A promise of response object containing the audio stream for the specified byte range
*
* Fetches the audio stream for a song.
*/
getAudioStream: (id: string, headers: Headers) => Promise<Response>
/**
* @param id The id of an album
* @returns A promise of the album as an Album object
*/
getAlbum: (id: string) => Promise<Album>
/**
* @param id The id of an album
* @returns A promise of the songs in the album as and array of Song objects
*/
getAlbumItems: (id: string) => Promise<Song[]>
/**
* @param id The id of a playlist
* @returns A promise of the playlist of as a Playlist object
*/
getPlaylist: (id: string) => Promise<Playlist>
/**
* @param id The id of a playlist
* @returns A promise of the songs in the playlist as and array of Song objects
*/
getPlaylistItems: (id: string) => Promise<Song[]>
} }
// These Schemas should only contain general info data that is necessary for data fetching purposes. // These Schemas should only contain general info data that is necessary for data fetching purposes.
@@ -84,6 +124,7 @@ declare global {
isVideo: boolean isVideo: boolean
} }
// Properties like duration and track count are properties of album items not the album itself
type Album = { type Album = {
connection: { connection: {
id: string id: string
@@ -92,15 +133,13 @@ declare global {
id: string id: string
name: string name: string
type: 'album' type: 'album'
duration?: number // Seconds
thumbnailUrl: string thumbnailUrl: string
artists: { // Should try to order artists: { // Should try to order
id: string id: string
name: string name: string
profilePicture?: string profilePicture?: string
}[] | 'Various Artists' }[] | 'Various Artists'
releaseDate?: string // ISOString releaseYear?: string // ####
length?: number
} }
// Need to figure out how to do Artists, maybe just query MusicBrainz? // Need to figure out how to do Artists, maybe just query MusicBrainz?
@@ -129,8 +168,9 @@ declare global {
name: string name: string
profilePicture?: string profilePicture?: string
} }
length: number
} }
type HasDefinedProperty<T, K extends keyof T> = T & { [P in K]-?: Exclude<T[P], undefined> };
} }
export {} export {}

View File

@@ -8,8 +8,23 @@ export const handle: Handle = async ({ event, resolve }) => {
if (urlpath.startsWith('/api')) { if (urlpath.startsWith('/api')) {
const unprotectedAPIRoutes = ['/api/audio', '/api/remoteImage'] const unprotectedAPIRoutes = ['/api/audio', '/api/remoteImage']
const apikey = event.request.headers.get('apikey') || event.url.searchParams.get('apikey')
if (!unprotectedAPIRoutes.includes(urlpath) && apikey !== SECRET_INTERNAL_API_KEY) { function checkAuthorization(): boolean {
const apikey = event.request.headers.get('apikey') || event.url.searchParams.get('apikey')
if (apikey === SECRET_INTERNAL_API_KEY) return true
const authToken = event.cookies.get('lazuli-auth')
if (!authToken) return false
try {
jwt.verify(authToken, SECRET_JWT_KEY)
return true
} catch {
return false
}
}
if (!unprotectedAPIRoutes.includes(urlpath) && !checkAuthorization()) {
return new Response('Unauthorized', { status: 401 }) return new Response('Unauthorized', { status: 401 })
} }
} }

View File

@@ -8,10 +8,19 @@
let queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly let queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly
let image: HTMLImageElement, captionText: HTMLDivElement let image: HTMLImageElement, captionText: HTMLDivElement
async function setQueueItems(mediaItem: Album | Playlist) {
const itemsResponse = await fetch(`/api/connections/${mediaItem.connection.id}/${mediaItem.type}/${mediaItem.id}/items`, {
credentials: 'include',
}).then((response) => response.json() as Promise<{ items: Song[] }>)
const items = itemsResponse.items
queueRef.setQueue(...items)
}
</script> </script>
<div id="card-wrapper" class="flex-shrink-0"> <div id="card-wrapper" class="flex-shrink-0">
<button id="thumbnail" class="relative h-52 transition-all duration-200 ease-out" on:click={() => goto(`/details/${mediaItem.type}?id=${mediaItem.id}`)}> <button id="thumbnail" class="relative h-52 transition-all duration-200 ease-out" on:click={() => goto(`/details/${mediaItem.type}?id=${mediaItem.id}&connection=${mediaItem.connection.id}`)}>
{#if 'thumbnailUrl' in mediaItem || 'profilePicture' in mediaItem} {#if 'thumbnailUrl' in mediaItem || 'profilePicture' in mediaItem}
<img <img
bind:this={image} bind:this={image}
@@ -30,7 +39,15 @@
<IconButton <IconButton
halo={true} halo={true}
on:click={() => { on:click={() => {
if (mediaItem.type === 'song') queueRef.current = mediaItem switch (mediaItem.type) {
case 'song':
queueRef.current = mediaItem
break
case 'album':
case 'playlist':
setQueueItems(mediaItem)
break
}
}} }}
> >
<i slot="icon" class="fa-solid fa-play text-xl" /> <i slot="icon" class="fa-solid fa-play text-xl" />
@@ -44,10 +61,9 @@
{#if mediaItem.artists === 'Various Artists'} {#if mediaItem.artists === 'Various Artists'}
<span class="text-sm">Various Artists</span> <span class="text-sm">Various Artists</span>
{:else} {:else}
{#each mediaItem.artists as artist} {#each mediaItem.artists as artist, index}
{@const listIndex = mediaItem.artists.indexOf(artist)}
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a> <a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
{#if listIndex < mediaItem.artists.length - 1} {#if index < mediaItem.artists.length - 1}
<span class="mr-0.5 text-sm">,</span> <span class="mr-0.5 text-sm">,</span>
{/if} {/if}
{/each} {/each}

View File

@@ -4,6 +4,7 @@
import { queue } from '$lib/stores' import { queue } from '$lib/stores'
// import { FastAverageColor } from 'fast-average-color' // import { FastAverageColor } from 'fast-average-color'
import Slider from '$lib/components/util/slider.svelte' import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.svelte'
$: currentlyPlaying = $queue.current $: currentlyPlaying = $queue.current
@@ -16,6 +17,10 @@
let volume: number, let volume: number,
muted = false muted = false
const maxVolume = 0.5
let waiting: boolean
$: muted ? (volume = 0) : (volume = Number(localStorage.getItem('volume'))) $: muted ? (volume = 0) : (volume = Number(localStorage.getItem('volume')))
$: if (volume && !muted) localStorage.setItem('volume', volume.toString()) $: if (volume && !muted) localStorage.setItem('volume', volume.toString())
@@ -45,7 +50,7 @@
if (storedVolume) { if (storedVolume) {
volume = Number(storedVolume) volume = Number(storedVolume)
} else { } else {
localStorage.setItem('volume', '0.5') localStorage.setItem('volume', (maxVolume / 2).toString())
volume = 0.5 volume = 0.5
} }
@@ -76,6 +81,14 @@
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime) $: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime }) $: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration) $: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
let slidingText: HTMLElement
let slidingTextWidth: number, slidingTextWrapperWidth: number
let scrollDirection: 1 | -1 = 1
$: scrollDistance = slidingTextWidth - slidingTextWrapperWidth
$: if (slidingText && scrollDistance > 0) slidingText.style.animationDuration = `${scrollDistance / 50}s`
let audioElement: HTMLAudioElement
</script> </script>
{#if currentlyPlaying} {#if currentlyPlaying}
@@ -90,7 +103,7 @@
</div> </div>
<section class="flex flex-col justify-center gap-1"> <section class="flex flex-col justify-center gap-1">
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div> <div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div> <div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') ?? currentlyPlaying.uploader?.name}</div>
</section> </section>
</section> </section>
<section class="flex min-w-max flex-col items-center justify-center gap-1"> <section class="flex min-w-max flex-col items-center justify-center gap-1">
@@ -101,8 +114,12 @@
<button class="aspect-square h-8" on:click={() => $queue.previous()}> <button class="aspect-square h-8" on:click={() => $queue.previous()}>
<i class="fa-solid fa-backward-step" /> <i class="fa-solid fa-backward-step" />
</button> </button>
<button on:click={() => (paused = !paused)} class="grid aspect-square h-8 place-items-center rounded-full bg-white"> <button on:click={() => (paused = !paused)} class="relative grid aspect-square h-8 place-items-center rounded-full bg-white text-black">
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-black" /> {#if waiting}
<Loader size={1} />
{:else}
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
{/if}
</button> </button>
<button class="aspect-square h-8" on:click={() => $queue.next()}> <button class="aspect-square h-8" on:click={() => $queue.next()}>
<i class="fa-solid fa-forward-step" /> <i class="fa-solid fa-forward-step" />
@@ -133,10 +150,10 @@
<section class="flex items-center justify-end gap-2 pr-2 text-lg"> <section class="flex items-center justify-end gap-2 pr-2 text-lg">
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2"> <div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
<button on:click={() => (muted = !muted)} class="aspect-square h-8"> <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" /> <i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
</button> </button>
<div id="slider-wrapper" class="w-24 transition-all duration-500"> <div id="slider-wrapper" class="w-24 transition-all duration-500">
<Slider bind:value={volume} max={1} /> <Slider bind:value={volume} max={maxVolume} />
</div> </div>
</div> </div>
<button class="aspect-square h-8" on:click={() => (expanded = true)}> <button class="aspect-square h-8" on:click={() => (expanded = true)}>
@@ -148,30 +165,32 @@
</section> </section>
</main> </main>
{:else} {:else}
<main in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="expanded-player h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});"> <main in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="expanded-player relative h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});">
<img class="absolute -z-10 h-full w-full object-cover object-center blur-xl brightness-[25%]" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
<section class="song-queue-wrapper h-full px-24 py-20"> <section class="song-queue-wrapper h-full px-24 py-20">
<section class="relative"> <section class="relative">
{#key currentlyPlaying} {#key currentlyPlaying}
<img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" /> <img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
{/key} {/key}
</section> </section>
<section class="flex flex-col gap-2"> <section class="no-scrollbar flex max-h-full flex-col gap-3 overflow-y-scroll">
<div class="ml-2 text-2xl">Up next</div> <strong class="ml-2 text-2xl">UP NEXT</strong>
{#each $queue.list as item, index} {#each $queue.list as item}
{@const isCurrent = item === currentlyPlaying} {@const isCurrent = item === currentlyPlaying}
<button <button
on:click={() => { on:click={() => {
if (!isCurrent) $queue.current = item if (!isCurrent) $queue.current = item
}} }}
class="queue-item w-full items-center gap-3 rounded-xl p-3 {isCurrent ? 'bg-[rgba(64,_64,_64,_0.5)]' : 'bg-[rgba(10,_10,_10,_0.5)]'}" class="queue-item h-20 w-full shrink-0 items-center gap-3 overflow-clip rounded-lg bg-neutral-900 {isCurrent
? 'pointer-events-none border-[1px] border-neutral-300'
: 'hover:bg-neutral-800'}"
> >
<div class="justify-self-center">{index + 1}</div> <div class="h-20 w-20 bg-cover bg-center" style="background-image: url('/api/remoteImage?url={item.thumbnailUrl}');" />
<img class="justify-self-center" src="/api/remoteImage?url={item.thumbnailUrl}" alt="" draggable="false" />
<div class="justify-items-left text-left"> <div class="justify-items-left text-left">
<div class="line-clamp-2">{item.name}</div> <div class="line-clamp-1">{item.name}</div>
<div class="mt-[.15rem] text-neutral-500">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div> <div class="mt-[.15rem] line-clamp-1 text-neutral-400">{item.artists?.map((artist) => artist.name).join(', ') ?? item.uploader?.name}</div>
</div> </div>
<span class="text-right">{formatTime(item.duration)}</span> <span class="mr-4 text-right">{formatTime(item.duration)}</span>
</button> </button>
{/each} {/each}
</section> </section>
@@ -194,10 +213,42 @@
<span bind:this={expandedDurationTimestamp} class="text-left" /> <span bind:this={expandedDurationTimestamp} class="text-left" />
</div> </div>
<div class="expanded-controls"> <div class="expanded-controls">
<div> <div class="flex flex-col gap-2 overflow-hidden">
<div class="mb-2 line-clamp-2 text-3xl">{currentlyPlaying.name}</div> <div bind:clientWidth={slidingTextWrapperWidth} class="relative h-9 w-full">
<div class="line-clamp-1 text-lg"> <strong
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}{currentlyPlaying.album ? ` - ${currentlyPlaying.album.name}` : ''} bind:this={slidingText}
bind:clientWidth={slidingTextWidth}
on:animationend={() => (scrollDirection *= -1)}
class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} scrollingText absolute whitespace-nowrap text-3xl">{currentlyPlaying.name}</strong
>
</div>
<div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;">
{#if 'artists' in currentlyPlaying && currentlyPlaying.artists && currentlyPlaying.artists.length > 0}
{#each currentlyPlaying.artists as artist, index}
<a
on:click={() => (expanded = false)}
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
href="/details/artist?id={artist.id}&connection={currentlyPlaying.connection.id}">{artist.name}</a
>
{#if index < currentlyPlaying.artists.length - 1}
<span class="mr-1 text-lg">,</span>
{/if}
{/each}
{:else if 'uploader' in currentlyPlaying && currentlyPlaying.uploader}
<a
on:click={() => (expanded = false)}
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
href="/details/user?id={currentlyPlaying.uploader.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.uploader.name}</a
>
{/if}
{#if currentlyPlaying.album}
<span class="mx-1.5 text-lg">-</span>
<a
on:click={() => (expanded = false)}
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a
>
{/if}
</div> </div>
</div> </div>
<div class="flex w-full items-center justify-center gap-2 text-2xl"> <div class="flex w-full items-center justify-center gap-2 text-2xl">
@@ -207,8 +258,12 @@
<button class="aspect-square h-16" on:click={() => $queue.previous()}> <button class="aspect-square h-16" on:click={() => $queue.previous()}>
<i class="fa-solid fa-backward-step" /> <i class="fa-solid fa-backward-step" />
</button> </button>
<button on:click={() => (paused = !paused)} class="grid aspect-square h-16 place-items-center rounded-full bg-white"> <button on:click={() => (paused = !paused)} class="relative grid aspect-square h-16 place-items-center rounded-full bg-white text-black">
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-black" /> {#if waiting}
<Loader size={2.5} />
{:else}
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
{/if}
</button> </button>
<button class="aspect-square h-16" on:click={() => $queue.next()}> <button class="aspect-square h-16" on:click={() => $queue.next()}>
<i class="fa-solid fa-forward-step" /> <i class="fa-solid fa-forward-step" />
@@ -220,10 +275,10 @@
<section class="flex items-center justify-end gap-2 text-xl"> <section class="flex items-center justify-end gap-2 text-xl">
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2"> <div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
<button on:click={() => (muted = !muted)} class="aspect-square h-8"> <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" /> <i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
</button> </button>
<div id="slider-wrapper" class="w-24 transition-all duration-500"> <div id="slider-wrapper" class="w-24 transition-all duration-500">
<Slider bind:value={volume} max={1} /> <Slider bind:value={volume} max={maxVolume} />
</div> </div>
</div> </div>
<button class="aspect-square h-8" on:click={() => (expanded = false)}> <button class="aspect-square h-8" on:click={() => (expanded = false)}>
@@ -237,18 +292,27 @@
</section> </section>
</main> </main>
{/if} {/if}
<audio autoplay bind:paused bind:volume bind:currentTime bind:duration on:ended={() => $queue.next()} src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}" /> <audio
bind:this={audioElement}
autoplay
bind:paused
bind:volume
bind:currentTime
bind:duration
on:canplay={() => (waiting = false)}
on:loadstart={() => (waiting = true)}
on:waiting={() => (waiting = true)}
on:ended={() => $queue.next()}
on:error={() => setTimeout(() => audioElement.load(), 5000)}
src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
/>
</div> </div>
{/if} {/if}
<style> <style>
.expanded-player { .expanded-player {
display: grid; display: grid;
grid-template-rows: auto 12rem; grid-template-rows: calc(100% - 12rem) 12rem;
/* background: linear-gradient(to left, rgba(16, 16, 16, 0.9), rgb(16, 16, 16)), var(--currentlyPlayingImage); */
background-repeat: no-repeat !important;
background-size: cover !important;
background-position: center !important;
} }
.song-queue-wrapper { .song-queue-wrapper {
display: grid; display: grid;
@@ -257,10 +321,7 @@
} }
.queue-item { .queue-item {
display: grid; display: grid;
grid-template-columns: 1rem 50px auto min-content; grid-template-columns: 5rem auto min-content;
}
.queue-item:hover {
background-color: rgba(64, 64, 64, 0.5);
} }
.progress-bar-expanded { .progress-bar-expanded {
display: grid; display: grid;
@@ -270,6 +331,44 @@
} }
.expanded-controls { .expanded-controls {
display: grid; display: grid;
grid-template-columns: 1fr min-content 1fr; gap: 1rem;
grid-template-columns: 1fr min-content 1fr !important;
}
.scrollingText {
animation-timing-function: linear;
animation-fill-mode: both;
animation-delay: 10s;
}
.scrollingText:hover {
animation-play-state: paused;
}
.scrollLeft {
animation-name: scrollLeft;
}
.scrollRight {
animation-name: scrollRight;
}
@keyframes scrollLeft {
0% {
left: 0%;
transform: translateX(0%);
}
100% {
left: 100%;
transform: translateX(-100%);
}
}
@keyframes scrollRight {
0% {
left: 100%;
transform: translateX(-100%);
}
100% {
left: 0%;
transform: translateX(0%);
}
} }
</style> </style>

View File

@@ -1,6 +1,9 @@
<!-- Credit to https://cssloaders.github.io/ --> <!-- Credit to https://cssloaders.github.io/ -->
<script lang="ts">
export let size = 5
</script>
<span id="loader" class="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2" /> <span id="loader" style="height: {size}rem; width: {size}rem;" class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
<style> <style>
#loader:after { #loader:after {

View File

@@ -16,9 +16,11 @@
$: trackThumb(value) $: trackThumb(value)
onMount(() => trackThumb(value)) onMount(() => trackThumb(value))
const keyPressJumpIntervalCount = 20
const handleKeyPress = (key: string) => { const handleKeyPress = (key: string) => {
if ((key === 'ArrowRight' || key === 'ArrowUp') && value < 1) return (value += 1) if ((key === 'ArrowRight' || key === 'ArrowUp') && value < max) value = Math.min(max, value + max / keyPressJumpIntervalCount)
if ((key === 'ArrowLeft' || key === 'ArrowDown') && value > 0) return (value -= 1) if ((key === 'ArrowLeft' || key === 'ArrowDown') && value > 0) value = Math.max(0, value - max / keyPressJumpIntervalCount) // For some reason this is kinda broken
} }
</script> </script>

View File

@@ -1,8 +1,10 @@
import { DB, type ConnectionRow } from './db' import { DB } from './db'
import { Jellyfin } from './jellyfin' import { Jellyfin } from './jellyfin'
import { YouTubeMusic } from './youtube-music' import { YouTubeMusic } from './youtube-music'
const constructConnection = (connectionInfo: ConnectionRow): Connection => { const constructConnection = (connectionInfo: ReturnType<typeof DB.getConnectionInfo>): Connection | undefined => {
if (!connectionInfo) return undefined
const { id, userId, type, service, tokens } = connectionInfo const { id, userId, type, service, tokens } = connectionInfo
switch (type) { switch (type) {
case 'jellyfin': case 'jellyfin':
@@ -11,20 +13,15 @@ const constructConnection = (connectionInfo: ConnectionRow): Connection => {
return new YouTubeMusic(id, userId, service.userId, tokens.accessToken, tokens.refreshToken, tokens.expiry) return new YouTubeMusic(id, userId, service.userId, tokens.accessToken, tokens.refreshToken, tokens.expiry)
} }
} }
function getConnection(id: string): Connection | undefined {
const getConnections = (ids: string[]): Connection[] => { return constructConnection(DB.getConnectionInfo(id))
const connectionInfo = DB.getConnectionInfo(ids)
return Array.from(connectionInfo, (info) => constructConnection(info))
} }
const getUserConnections = (userId: string): Connection[] => { const getUserConnections = (userId: string): Connection[] | undefined => {
const connectionInfo = DB.getUserConnectionInfo(userId) return DB.getUserConnectionInfo(userId)?.map((info) => constructConnection(info)!)
return Array.from(connectionInfo, (info) => constructConnection(info))
} }
export const Connections = { export const Connections = {
getConnections, getConnection,
getUserConnections, getUserConnections,
} }

View File

@@ -67,12 +67,12 @@ class Storage {
} }
public getUser = (id: string): User | undefined => { public getUser = (id: string): User | undefined => {
const user = this.database.prepare(`SELECT * FROM Users WHERE id = ?`).get(id) as User | undefined const user = this.database.prepare(`SELECT * FROM Users WHERE id = ? LIMIT 1`).get(id) as User | undefined
return user return user
} }
public getUsername = (username: string): User | undefined => { public getUsername = (username: string): User | undefined => {
const user = this.database.prepare(`SELECT * FROM Users WHERE lower(username) = ?`).get(username.toLowerCase()) as User | undefined const user = this.database.prepare(`SELECT * FROM Users WHERE lower(username) = ? LIMIT 1`).get(username.toLowerCase()) as User | undefined
return user return user
} }
@@ -87,21 +87,20 @@ class Storage {
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`) if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
} }
public getConnectionInfo = (ids: string[]): ConnectionRow[] => { public getConnectionInfo = (id: string): ConnectionRow | undefined => {
const connectionInfo: ConnectionRow[] = [] const result = this.database.prepare(`SELECT * FROM Connections WHERE id = ? LIMIT 1`).get(id) as DBConnectionsTableSchema | undefined
for (const id of ids) { if (!result) return undefined
const result = this.database.prepare(`SELECT * FROM Connections WHERE id = ?`).get(id) as DBConnectionsTableSchema | undefined
if (!result) continue
const { userId, type, service, tokens } = result const { userId, type, service, tokens } = result
const parsedService = service ? JSON.parse(service) : undefined const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined const parsedTokens = tokens ? JSON.parse(tokens) : undefined
connectionInfo.push({ id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens }) return { id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens }
}
return connectionInfo
} }
public getUserConnectionInfo = (userId: string): ConnectionRow[] => { public getUserConnectionInfo = (userId: string): ConnectionRow[] | undefined => {
const user = this.getUser(userId)
if (!user) return undefined
const connectionRows = this.database.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as DBConnectionsTableSchema[] const connectionRows = this.database.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as DBConnectionsTableSchema[]
const connections: ConnectionRow[] = [] const connections: ConnectionRow[] = []
for (const { id, type, service, tokens } of connectionRows) { for (const { id, type, service, tokens } of connectionRows) {

View File

@@ -210,92 +210,153 @@ export namespace InnerTube {
} }
} }
interface AlbumResponse { namespace Album {
contents: { interface AlbumResponse {
singleColumnBrowseResultsRenderer: { contents: {
tabs: [ singleColumnBrowseResultsRenderer: {
{ tabs: [
tabRenderer: { {
content: { tabRenderer: {
sectionListRenderer: { content: {
contents: [ sectionListRenderer: {
{ contents: [
musicShelfRenderer: { {
contents: Array<{ musicShelfRenderer: {
musicResponsiveListItemRenderer: { contents: Array<AlbumItem>
flexColumns: Array<{ continuations?: [
musicResponsiveListItemFlexColumnRenderer: { {
text: { nextContinuationData: {
runs?: [ continuation: string
{
text: string
navigationEndpoint?: {
watchEndpoint: watchEndpoint
}
},
]
}
} }
}> },
fixedColumns: [ ]
{ }
musicResponsiveListItemFixedColumnRenderer: { },
text: { ]
runs: [ }
{
text: string
},
]
}
}
},
]
}
}>
}
},
]
} }
} }
},
]
}
}
header: {
musicDetailHeaderRenderer: {
title: {
runs: [
{
text: string
},
]
}
subtitle: {
// Alright let's break down this dumbass pattern. First run will always have the text 'Album', last will always be the release year. Interspersed throughout the middle will be the artist runs
// which, if they have a dedicated channel, will have a navigation endpoint. Every other run is some kind of delimiter (• , &). Because y'know, it's perfectly sensible to include your decorative
// elements in your api responses /s
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: browseEndpoint
}
}>
}
secondSubtitle: {
// Slightly less dumbass. Three runs, first is the number of songs in the format: "# songs". Second is another bullshit delimiter. Last is the album's duration, spelled out rather than as a timestamp
// for god knows what reason. Duration follows the following format: "# hours, # minutes" or just "# minutes".
runs: {
text: string
}[]
}
thumbnail: {
croppedSquareThumbnailRenderer: musicThumbnailRenderer
}
}
}
}
type AlbumItem = {
musicResponsiveListItemRenderer: {
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
navigationEndpoint: {
watchEndpoint: watchEndpoint
}
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs?: {
text: string
navigationEndpoint?: {
browseEndpoint: browseEndpoint
}
}[]
}
}
},
]
fixedColumns: [
{
musicResponsiveListItemFixedColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
} }
}, },
] ]
} }
} }
header: {
musicDetailHeaderRenderer: { type ContentShelf = {
title: { contents: Array<AlbumItem>
runs: [ continuations?: [
{ {
text: string nextContinuationData: {
}, continuation: string
] }
} },
subtitle: { ]
// Alright let's break down this dumbass pattern. First run will always have the text 'Album', last will always be the release year. Interspersed throughout the middle will be the artist runs }
// which, if they have a dedicated channel, will have a navigation endpoint. Every other run is some kind of delimiter (• , &). Because y'know, it's perfectly sensible to include your decorative
// elements in your api responses /s interface ContinuationResponse {
runs: Array<{ continuationContents: {
text: string musicShelfContinuation: ContentShelf
navigationEndpoint?: {
browseEndpoint: browseEndpoint
}
}>
}
secondSubtitle: {
// Slightly less dumbass. Three runs, first is the number of songs in the format: "# songs". Second is another bullshit delimiter. Last is the album's duration, spelled out rather than as a timestamp
// for god knows what reason. Duration follows the following format: "# hours, # minutes" or just "# minutes".
runs: {
text: string
}[]
}
thumbnail: {
croppedSquareThumbnailRenderer: musicThumbnailRenderer
}
} }
} }
} }
namespace Player {
interface PlayerResponse {
streamingData: {
formats: Format[]
adaptiveFormats: Format[]
}
}
type Format = {
itag: number
url?: string // Only present for Android client requests, not web requests
mimeType: string
bitrate: number
qualityLabel?: string // If present, format contains video (144p, 240p, 360p, etc.)
audioQuality?: string // If present, format contains audio (AUDIO_QUALITY_LOW, AUDIO_QUALITY_MEDIUM, AUDIO_QUALITY_HIGH)
signatureCipher?: string // Only present for Web client requests, not android requests
}
}
interface SearchResponse { interface SearchResponse {
contents: { contents: {
tabbedSearchResultsRenderer: { tabbedSearchResultsRenderer: {
@@ -312,6 +373,9 @@ export namespace InnerTube {
| { | {
musicShelfRenderer: musicShelfRenderer musicShelfRenderer: musicShelfRenderer
} }
| {
itemSectionRenderer: unknown
}
> >
} }
} }
@@ -397,7 +461,7 @@ export namespace InnerTube {
title: { title: {
runs: [ runs: [
{ {
text: 'Listen again' | 'Forgotten favorites' | 'Quick picks' | 'New releases' | 'From your library' text: string
}, },
] ]
} }

View File

@@ -1,5 +1,4 @@
import { google, type youtube_v3 } from 'googleapis' import { google, type youtube_v3 } from 'googleapis'
import ytdl from 'ytdl-core'
import { DB } from './db' import { DB } from './db'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
@@ -56,22 +55,7 @@ export class YouTubeMusic implements Connection {
this.expiry = expiry this.expiry = expiry
} }
private get innertubeRequestHeaders() { // TODO: Need to figure out a way to prevent this from this refresh the access token twice in the event that it is requested again while awaiting the first refreshed token
return (async () => {
return new Headers({
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
accept: '*/*',
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json',
'content-encoding': 'gzip',
origin: 'https://music.youtube.com',
Cookie: 'SOCS=CAI;',
authorization: `Bearer ${await this.accessToken}`,
'X-Goog-Request-Time': `${Date.now()}`,
})
})()
}
private get accessToken() { private get accessToken() {
return (async () => { return (async () => {
const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => { const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => {
@@ -124,114 +108,207 @@ export class YouTubeMusic implements Connection {
} }
private async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) { private async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) {
const headers = new Headers({
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
accept: '*/*',
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json',
'content-encoding': 'gzip',
origin: 'https://music.youtube.com',
authorization: `Bearer ${await this.accessToken}`,
})
const currentDate = new Date() const currentDate = new Date()
const year = currentDate.getUTCFullYear().toString() const year = currentDate.getUTCFullYear().toString()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1 const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0') const day = currentDate.getUTCDate().toString().padStart(2, '0')
let url = 'https://music.youtube.com/youtubei/v1/' const context = {
client: {
const body: { [k: string]: any } = { clientName: 'WEB_REMIX',
context: { clientVersion: `1.${year + month + day}.01.00`,
client: { hl: 'en',
clientName: 'WEB_REMIX',
clientVersion: `1.${year + month + day}.01.00`,
hl: 'en',
},
}, },
} }
switch (requestDetails.type) { const fetchData = (): [URL, Object] => {
case 'browse': switch (requestDetails.type) {
url = url.concat('browse') case 'browse':
body['browseId'] = requestDetails.browseId return [
break new URL('https://music.youtube.com/youtubei/v1/browse'),
case 'search': {
url = url.concat('search') browseId: requestDetails.browseId,
if (requestDetails.filter) body['params'] = searchFilterParams[requestDetails.filter] context,
body['query'] = requestDetails.searchTerm },
break ]
case 'continuation': case 'search':
url = url.concat(`browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`) return [
break new URL('https://music.youtube.com/youtubei/v1/search'),
{
query: requestDetails.searchTerm,
filter: requestDetails.filter ? searchFilterParams[requestDetails.filter] : undefined,
context,
},
]
case 'continuation':
return [
new URL(`https://music.youtube.com/youtubei/v1/browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`),
{
context,
},
]
}
} }
return fetch(url, { const [url, body] = fetchData()
headers: await this.innertubeRequestHeaders,
method: 'POST', return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) })
body: JSON.stringify(body),
}).then((response) => response.json())
} }
// TODO: Figure out why this still breaks sometimes
public async search(searchTerm: string, filter: 'song'): Promise<Song[]>
public async search(searchTerm: string, filter: 'album'): Promise<Album[]>
public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]>
public async search(searchTerm: string, filter: 'playlist'): Promise<Playlist[]>
public async search(searchTerm: string, filter?: undefined): Promise<(Song | Album | Artist | Playlist)[]>
public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> { public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> {
// Figure out how to handle Library and Uploads // Figure out how to handle Library and Uploads
// Depending on how I want to handle the playlist & library sync feature // Depending on how I want to handle the playlist & library sync feature
const searchResulsts = (await this.ytMusicv1ApiRequest({ type: 'search', searchTerm, filter })) as InnerTube.SearchResponse const searchResulsts = (await this.ytMusicv1ApiRequest({ type: 'search', searchTerm, filter }).then((response) => response.json())) as InnerTube.SearchResponse
const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
const parsedSearchResults: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = [] try {
const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists'] const parsedSearchResults = []
for (const section of contents) { const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists']
if ('musicCardShelfRenderer' in section) { for (const section of contents) {
parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer)) if ('itemSectionRenderer' in section) continue
section.musicCardShelfRenderer.contents?.forEach((item) => {
if ('musicResponsiveListItemRenderer' in item) { if ('musicCardShelfRenderer' in section) {
parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer))
} section.musicCardShelfRenderer.contents?.forEach((item) => {
}) if ('musicResponsiveListItemRenderer' in item) {
continue parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
}
})
continue
}
const sectionType = section.musicShelfRenderer.title.runs[0].text
if (!goodSections.includes(sectionType)) continue
const parsedSectionContents = section.musicShelfRenderer.contents.map((item) => parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
parsedSearchResults.push(...parsedSectionContents)
} }
const sectionType = section.musicShelfRenderer.title.runs[0].text return this.scrapedToMediaItems(parsedSearchResults)
if (!goodSections.includes(sectionType)) continue } catch (error) {
console.log(error)
const parsedSectionContents = section.musicShelfRenderer.contents.map((item) => parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) console.log(JSON.stringify(contents))
parsedSearchResults.push(...parsedSectionContents) throw Error('Something fucked up')
} }
return await this.scrapedToMediaItems(parsedSearchResults)
} }
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> { // TODO: Figure out why this still breaks sometimes
const homeResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' })) as InnerTube.HomeResponse public async getRecommendations() {
const homeResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse
const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
const scrapedRecommendations: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = [] try {
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library'] const scrapedRecommendations: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = []
for (const section of contents) { const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library', 'Recommended music videos', 'Recommended albums']
const sectionType = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text for (const section of contents) {
if (!goodSections.includes(sectionType)) continue const sectionType = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text
if (!goodSections.includes(sectionType)) continue
const parsedContent = section.musicCarouselShelfRenderer.contents.map((content) => const parsedContent = section.musicCarouselShelfRenderer.contents.map((content) =>
'musicTwoRowItemRenderer' in content ? parseTwoRowItemRenderer(content.musicTwoRowItemRenderer) : parseResponsiveListItemRenderer(content.musicResponsiveListItemRenderer), 'musicTwoRowItemRenderer' in content ? parseTwoRowItemRenderer(content.musicTwoRowItemRenderer) : parseResponsiveListItemRenderer(content.musicResponsiveListItemRenderer),
) )
scrapedRecommendations.push(...parsedContent) scrapedRecommendations.push(...parsedContent)
}
return this.scrapedToMediaItems(scrapedRecommendations)
} catch (error) {
console.log(error)
console.log(JSON.stringify(contents))
throw Error('Something fucked up')
} }
return await this.scrapedToMediaItems(scrapedRecommendations)
} }
public async getAudioStream(id: string, range: string | null): Promise<Response> { public async getAudioStream(id: string, headers: Headers): Promise<Response> {
const videoInfo = await ytdl.getInfo(`http://www.youtube.com/watch?v=${id}`) if (!/^[a-zA-Z0-9-_]{11}$/.test(id)) throw TypeError('Invalid youtube video ID')
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
const headers = new Headers({ range: range || '0-' }) // ? In the future, may want to implement the original web client method both in order to bypass age-restrictions and just to serve as a fallback
// ? However this has the downsides of being slower and requiring the user's cookies if the video is premium exclusive.
// ? Ideally, I want to avoid having to mess with a user's cookies at all costs because:
// ? a) It's another security risk
// ? b) A user would have to manually copy them over, which is about as user friendly as a kick to the face
// ? c) Cookies get updated with every request, meaning the db would get hit more frequently, and it's just another thing to maintain
// ? Ulimately though, I may have to implment cookie support anyway dependeding on how youtube tracks a user's watch history and prefrences
return await fetch(format.url, { headers }) // * MASSIVE props and credit to Oleksii Holub (https://github.com/Tyrrrz) for documenting the android client method of player fetching: https://tyrrrz.me/blog/reverse-engineering-youtube-revisited.
// * Go support him and go support Ukraine (he's Ukrainian)
// TODO: Differentiate errors thrown by the player fetch and handle them respectively, rather than just a global catch. (Throw TypeError if the request contained an invalid videoId)
const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', {
headers: {
'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip',
accept: '*/*',
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json',
'content-encoding': 'gzip',
origin: 'https://music.youtube.com',
authorization: `Bearer ${await this.accessToken}`,
},
method: 'POST',
body: JSON.stringify({
videoId: id,
context: {
client: {
clientName: 'ANDROID_TESTSUITE',
clientVersion: '1.9',
androidSdkVersion: 30,
hl: 'en',
gl: 'US',
utcOffsetMinutes: 0,
},
},
}),
})
.then((response) => response.json() as Promise<InnerTube.Player.PlayerResponse>)
.catch(() => null)
if (!playerResponse) throw Error(`Failed to fetch player for song ${id} of connection ${this.id}`)
const audioOnlyFormats = playerResponse.streamingData.formats.concat(playerResponse.streamingData.adaptiveFormats).filter(
(format): format is HasDefinedProperty<InnerTube.Player.Format, 'url' | 'audioQuality'> =>
format.qualityLabel === undefined &&
format.audioQuality !== undefined &&
format.url !== undefined &&
!/\bsource[/=]yt_live_broadcast\b/.test(format.url) && // Filters out live broadcasts
!/\/manifest\/hls_(variant|playlist)\//.test(format.url) && // Filters out HLS streams
!/\/manifest\/dash\//.test(format.url), // Filters out DashMPD streams
// ? For each of the three above filters, I may want to look into how to support them.
// ? Especially live streams, being able to support those live music stream channels seems like a necessary feature.
// ? HLS and DashMPD I *think* are more efficient so it would be nice to support those too.
)
if (audioOnlyFormats.length === 0) throw Error(`No valid audio formats returned for song ${id} of connection ${this.id}`)
const hqAudioFormat = audioOnlyFormats.reduce((previous, current) => (previous.bitrate > current.bitrate ? previous : current))
return fetch(hqAudioFormat.url, { headers })
} }
/** /**
* @param id The browseId of the album * @param id The browseId of the album
* @returns Basic info about the album in the Album type schema.
*/ */
public async getAlbum(id: string): Promise<Album> { public async getAlbum(id: string): Promise<Album> {
const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id })) as InnerTube.AlbumResponse const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
const header = albumResponse.header.musicDetailHeaderRenderer const header = albumResponse.header.musicDetailHeaderRenderer
const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection'] const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
const name = header.title.runs[0].text, const name = header.title.runs[0].text,
@@ -249,23 +326,82 @@ export class YouTubeMusic implements Connection {
} }
} }
const releaseDate = header.subtitle.runs.at(-1)?.text!, const releaseYear = header.subtitle.runs.at(-1)?.text!
length = contents.length
const duration = contents.reduce( return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
(accumulator, current) => (accumulator += timestampToSeconds(current.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)), }
0,
)
return { connection, id, name, type: 'album', duration, thumbnailUrl, artists, releaseDate, length } satisfies Album /**
* @param id The browseId of the album
*/
public async getAlbumItems(id: string): Promise<Song[]> {
const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
const header = albumResponse.header.musicDetailHeaderRenderer
const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents
let continuation = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.continuations?.[0].nextContinuationData.continuation
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const thumbnailUrl = cleanThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails[0].url)
const album: Song['album'] = { id, name: header.title.runs[0].text }
const albumArtists = header.subtitle.runs
.filter((run) => run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ARTIST')
.map((run) => ({ id: run.navigationEndpoint!.browseEndpoint.browseId, name: run.text }))
while (continuation) {
const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Album.ContinuationResponse
contents.push(...continuationResponse.continuationContents.musicShelfContinuation.contents)
continuation = continuationResponse.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
}
// Just putting this here in the event that for some reason an album has non-playlable items, never seen it happen but couldn't hurt
const playableItems = contents.filter((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined)
const dividedItems = []
for (let i = 0; i < playableItems.length; i += 50) dividedItems.push(playableItems.slice(i, i + 50))
const access_token = await this.accessToken
const videoSchemas = await Promise.all(
dividedItems.map((chunk) =>
ytDataApi.videos.list({
part: ['snippet'],
id: chunk.map((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId),
access_token,
}),
),
).then((responses) => responses.map((response) => response.data.items!).flat())
const descriptionRelease = videoSchemas.find((video) => video.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] !== undefined)?.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0]
const releaseDate = new Date(descriptionRelease ?? header.subtitle.runs.at(-1)?.text!).toISOString()
const videoChannelMap = new Map<string, string>()
videoSchemas.forEach((video) => videoChannelMap.set(video.id!, video.snippet?.channelId!))
return playableItems.map((item) => {
const [col0, col1] = item.musicResponsiveListItemRenderer.flexColumns
const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
const artists = col1.musicResponsiveListItemFlexColumnRenderer.text.runs?.map((run) => ({ id: run.navigationEndpoint?.browseEndpoint.browseId ?? videoChannelMap.get(id)!, name: run.text })) ?? albumArtists
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo }
})
} }
/** /**
* @param id The id of the playlist (not the browseId!). * @param id The id of the playlist (not the browseId!).
* @returns Basic info about the playlist in the Playlist type schema.
*/ */
public async getPlaylist(id: string): Promise<Playlist> { public async getPlaylist(id: string): Promise<Playlist> {
const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })) as InnerTube.Playlist.PlaylistResponse const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }).then((response) => response.json())) as InnerTube.Playlist.PlaylistResponse
const header = const header =
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.header 'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.header
@@ -276,7 +412,7 @@ export class YouTubeMusic implements Connection {
const name = header.title.runs[0].text const name = header.title.runs[0].text
const thumbnailUrl = cleanThumbnailUrl( const thumbnailUrl = cleanThumbnailUrl(
header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url, header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url, // This is because sometimes the thumbnails for playlists can be video thumbnails
) )
let createdBy: Playlist['createdBy'] let createdBy: Playlist['createdBy']
@@ -284,61 +420,84 @@ export class YouTubeMusic implements Connection {
if (run.navigationEndpoint?.browseEndpoint.browseId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } if (run.navigationEndpoint?.browseEndpoint.browseId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
}) })
const trackCountText = header.secondSubtitle.runs.find((run) => run.text.includes('tracks'))!.text // "### tracks" return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
const length = Number(trackCountText.split(' ')[0])
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy, length } satisfies Playlist
} }
/**
* @param id The id of the playlist (not the browseId!).
*/
// TODO: Add startIndex and length parameters
public async getPlaylistItems(id: string): Promise<Song[]> { public async getPlaylistItems(id: string): Promise<Song[]> {
const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })) as InnerTube.Playlist.PlaylistResponse const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }).then((response) => response.json())) as InnerTube.Playlist.PlaylistResponse
const contents = playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents const contents = playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents
let continuation = let continuation =
playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation
while (continuation) { while (continuation) {
const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation })) as InnerTube.Playlist.ContinuationResponse const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Playlist.ContinuationResponse
contents.push(...continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents) contents.push(...continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents)
continuation = continuationResponse.continuationContents.musicPlaylistShelfContinuation.continuations?.[0].nextContinuationData.continuation continuation = continuationResponse.continuationContents.musicPlaylistShelfContinuation.continuations?.[0].nextContinuationData.continuation
} }
const playlistItems: InnerTube.ScrapedSong[] = [] // This is simply to handle completely fucked playlists where the playlist items might be missing navigation endpoints (e.g. Deleted Videos)
contents.forEach((item) => { // or in some really bad cases, have a navigationEndpoint, but not a watchEndpoint somehow (Possibly for unlisted/private content?)
const playableItems = contents.filter((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined)
const scrapedItems = playableItems.map((item) => {
const [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns const [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns
// This is simply to handle completely fucked playlists where the playlist items might be missing navigation endpoints (e.g. Deleted Videos) const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.videoId
// or in some really bad cases, have a navigationEndpoint, but not a watchEndpoint somehow (Possibly for unlisted/private content?)
if (!col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId) return
const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV' const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
const col2run = col2.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0] const col2run = col2.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
const album: Song['album'] = col2run ? { id: col2run.navigationEndpoint.browseEndpoint.browseId, name: col2run.text } : undefined const album: Song['album'] = col2run ? { id: col2run.navigationEndpoint.browseEndpoint.browseId, name: col2run.text } : undefined
let artists: Song['artists'] = [], let artists: { id?: string; name: string }[] | undefined = [],
uploader: Song['uploader'] uploader: { id?: string; name: string } | undefined
for (const run of col1.musicResponsiveListItemFlexColumnRenderer.text.runs) { for (const run of col1.musicResponsiveListItemFlexColumnRenderer.text.runs) {
if (!run.navigationEndpoint) continue
const pageType = run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType const pageType = run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } const runData = { id: run.navigationEndpoint?.browseEndpoint.browseId, name: run.text }
pageType === 'MUSIC_PAGE_TYPE_ARTIST' ? artists.push(runData) : (uploader = runData) pageType === 'MUSIC_PAGE_TYPE_ARTIST' ? artists.push(runData) : (uploader = runData)
} }
playlistItems.push({ id, name, type: 'song', thumbnailUrl, artists, album, uploader, isVideo }) if (artists.length === 0) artists = undefined
return { id, name, duration, thumbnailUrl, artists, album, uploader, isVideo }
}) })
return await this.scrapedToMediaItems(playlistItems) const dividedItems = []
for (let i = 0; i < scrapedItems.length; i += 50) dividedItems.push(scrapedItems.slice(i, i + 50))
const access_token = await this.accessToken
const videoSchemaMap = new Map<string, youtube_v3.Schema$Video>()
const videoSchemas = (await Promise.all(dividedItems.map((chunk) => ytDataApi.videos.list({ part: ['snippet'], id: chunk.map((item) => item.id), access_token })))).map((response) => response.data.items!).flat()
videoSchemas.forEach((schema) => videoSchemaMap.set(schema.id!, schema))
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
return scrapedItems.map((item) => {
const correspondingSchema = videoSchemaMap.get(item.id)!
const { id, name, duration, album, isVideo } = item
const existingThumbnail = item.thumbnailUrl
const artists = item.artists?.map((artist) => ({ id: artist.id ?? correspondingSchema.snippet?.channelId!, name: artist.name }))
const uploader = item.uploader ? { id: item.uploader?.id ?? correspondingSchema.snippet?.channelId!, name: item.uploader.name } : undefined
const videoThumbnails = correspondingSchema.snippet?.thumbnails!
const thumbnailUrl = existingThumbnail ?? videoThumbnails.maxres?.url ?? videoThumbnails.standard?.url ?? videoThumbnails.high?.url ?? videoThumbnails.medium?.url ?? videoThumbnails.default?.url!
const releaseDate = new Date(correspondingSchema.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? correspondingSchema.snippet?.publishedAt!).toISOString()
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, uploader, isVideo } satisfies Song
})
} }
private async scrapedToMediaItems<T extends (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[]>(scrapedItems: T): Promise<ScrapedMediaItemMap<T[number]>[]> { private async scrapedToMediaItems<T extends (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[]>(scrapedItems: T): Promise<ScrapedMediaItemMap<T[number]>[]> {
@@ -367,9 +526,17 @@ export class YouTubeMusic implements Connection {
} }
}) })
const songIdArray = Array.from(songIds)
const dividedIds: string[][] = []
for (let i = 0; i < songIdArray.length; i += 50) dividedIds.push(songIdArray.slice(i, i + 50))
const access_token = await this.accessToken const access_token = await this.accessToken
const getSongDetails = () => ytDataApi.videos.list({ part: ['snippet', 'contentDetails'], id: Array.from(songIds), access_token }) const getSongDetails = () =>
Promise.all(dividedIds.map((idsChunk) => ytDataApi.videos.list({ part: ['snippet', 'contentDetails'], id: idsChunk, access_token }))).then((responses) =>
responses.map((response) => response.data.items!).flat(),
)
// Oh FFS. Despite nothing documenting it ^this api can only query a maximum of 50 ids at a time. Addtionally, if you exceed that limit, it doesn't even give you the correct error, it says some nonsense about an invalid filter paramerter. FML.
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id))) const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
const getPlaylistDetails = () => Promise.all(Array.from(playlistIds).map((id) => this.getPlaylist(id))) const getPlaylistDetails = () => Promise.all(Array.from(playlistIds).map((id) => this.getPlaylist(id)))
@@ -378,7 +545,7 @@ export class YouTubeMusic implements Connection {
albumDetailsMap = new Map<string, Album>(), albumDetailsMap = new Map<string, Album>(),
playlistDetailsMap = new Map<string, Playlist>() playlistDetailsMap = new Map<string, Playlist>()
songDetails.data.items!.forEach((item) => songDetailsMap.set(item.id!, item)) songDetails.forEach((item) => songDetailsMap.set(item.id!, item))
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album)) albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
playlistDetails.forEach((playlist) => playlistDetailsMap.set(playlist.id, playlist)) playlistDetails.forEach((playlist) => playlistDetailsMap.set(playlist.id, playlist))
@@ -394,18 +561,11 @@ export class YouTubeMusic implements Connection {
const thumbnails = songDetails.snippet?.thumbnails! const thumbnails = songDetails.snippet?.thumbnails!
const thumbnailUrl = item.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url! const thumbnailUrl = item.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
let songReleaseDate = new Date(songDetails.snippet?.publishedAt!) const releaseDate = new Date(songDetails.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? songDetails.snippet?.publishedAt!).toISOString()
const albumDetails = album ? albumDetailsMap.get(album.id)! : undefined const albumDetails = album ? albumDetailsMap.get(album.id)! : undefined
const fullAlbum = (albumDetails ? { id: albumDetails.id, name: albumDetails.name, thumbnailUrl: albumDetails.thumbnailUrl } : undefined) satisfies Song['album'] const fullAlbum = (albumDetails ? { id: albumDetails.id, name: albumDetails.name, thumbnailUrl: albumDetails.thumbnailUrl } : undefined) satisfies Song['album']
if (albumDetails?.releaseDate) {
const albumReleaseDate = new Date(albumDetails.releaseDate)
if (albumReleaseDate.getFullYear() < songReleaseDate.getFullYear()) songReleaseDate = albumReleaseDate
}
const releaseDate = songReleaseDate.toISOString()
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album: fullAlbum, isVideo, uploader } satisfies Song return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album: fullAlbum, isVideo, uploader } satisfies Song
case 'album': case 'album':
return albumDetailsMap.get(item.id)! satisfies Album return albumDetailsMap.get(item.id)! satisfies Album
@@ -444,8 +604,7 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
if ('watchEndpoint' in rowContent.navigationEndpoint) { if ('watchEndpoint' in rowContent.navigationEndpoint) {
const id = rowContent.navigationEndpoint.watchEndpoint.videoId const id = rowContent.navigationEndpoint.watchEndpoint.videoId
const musicVideoType = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType const isVideo = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const isVideo = musicVideoType === 'MUSIC_VIDEO_TYPE_UGC' || musicVideoType === 'MUSIC_VIDEO_TYPE_OMV'
const thumbnailUrl: InnerTube.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url) const thumbnailUrl: InnerTube.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
let albumId: string | undefined let albumId: string | undefined
@@ -476,6 +635,8 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
default:
throw Error('Unexpected twoRowItem type: ' + pageType)
} }
} }
@@ -506,8 +667,9 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
if (!('navigationEndpoint' in listContent)) { if (!('navigationEndpoint' in listContent)) {
const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
const musicVideoType = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType const isVideo =
const isVideo = musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV' listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !==
'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0] const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
@@ -532,6 +694,8 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
default:
throw Error('Unexpected responsiveListItem type: ' + pageType)
} }
} }
@@ -563,8 +727,7 @@ function parseMusicCardShelfRenderer(cardContent: InnerTube.musicCardShelfRender
const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint
if ('watchEndpoint' in navigationEndpoint) { if ('watchEndpoint' in navigationEndpoint) {
const id = navigationEndpoint.watchEndpoint.videoId const id = navigationEndpoint.watchEndpoint.videoId
const musicVideoType = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType const isVideo = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const isVideo = musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.ScrapedSong return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.ScrapedSong
@@ -582,6 +745,8 @@ function parseMusicCardShelfRenderer(cardContent: InnerTube.musicCardShelfRender
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
default:
throw Error('Unexpected musicCardShelf type: ' + pageType)
} }
} }
@@ -639,3 +804,42 @@ const timestampToSeconds = (timestamp: string) =>
.split(':') .split(':')
.reverse() .reverse()
.reduce((accumulator, current, index) => (accumulator += Number(current) * 60 ** index), 0) .reduce((accumulator, current, index) => (accumulator += Number(current) * 60 ** index), 0)
// * This method is designed to parse the cookies returned from a yt response in the Set-Cookie headers.
// * Keeping it here in case I ever need to implement management of a user's youtube cookies
function parseAndSetCookies(response: Response) {
const setCookieHeaders = response.headers.getSetCookie().map((header) => {
const keyValueStrings = header.split('; ')
const [name, value] = keyValueStrings[0].split('=')
const result: Record<string, string | number | boolean> = { name, value }
keyValueStrings.slice(1).forEach((string) => {
const [key, value] = string.split('=')
switch (key.toLowerCase()) {
case 'domain':
result.domain = value
break
case 'max-age':
result.expirationDate = Date.now() / 1000 + Number(value)
break
case 'expires':
result.expirationDate = result.expirationDate ? new Date(value).getTime() / 1000 : result.expirationDate // Max-Age takes precedence
break
case 'path':
result.path = value
break
case 'secure':
result.secure = true
break
case 'httponly':
result.httpOnly = true
break
case 'samesite':
const lowercaseValue = value.toLowerCase()
result.sameSite = lowercaseValue === 'none' ? 'no_restriction' : lowercaseValue
break
}
})
console.log(JSON.stringify(result))
return result
})
}

View File

@@ -62,6 +62,12 @@ class Queue {
writableQueue.set(this) writableQueue.set(this)
} }
public setQueue(...songs: Song[]) {
this.songs = songs
this.currentPosition = songs.length === 0 ? -1 : 0
writableQueue.set(this)
}
public clear() { public clear() {
this.currentPosition = -1 this.currentPosition = -1
this.songs = [] this.songs = []

View File

@@ -0,0 +1,23 @@
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ fetch, url }) => {
const connectionId = url.searchParams.get('connection')
const id = url.searchParams.get('id')
async function getAlbum(): Promise<Album> {
const albumResponse = (await fetch(`/api/connections/${connectionId}/album?id=${id}`, {
headers: { apikey: SECRET_INTERNAL_API_KEY },
}).then((response) => response.json())) as { album: Album }
return albumResponse.album
}
async function getAlbumItems(): Promise<Song[]> {
const itemsResponse = (await fetch(`/api/connections/${connectionId}/album/${id}/items`, {
headers: { apikey: SECRET_INTERNAL_API_KEY },
}).then((response) => response.json())) as { items: Song[] }
return itemsResponse.items
}
return { albumDetails: Promise.all([getAlbum(), getAlbumItems()]) }
}

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import Loader from '$lib/components/util/loader.svelte'
import type { PageData } from './$types'
export let data: PageData
</script>
<main>
{#await data.albumDetails}
<Loader />
{:then [album, items]}
<section class="flex gap-8">
<img class="h-60" src="/api/remoteImage?url={album.thumbnailUrl}" alt="{album.name} cover art" />
<div>
<div class="text-4xl">{album.name}</div>
{#if album.artists === 'Various Artists'}
<div>Various Artists</div>
{:else}
<div style="font-size: 0;">
{#each album.artists as artist, index}
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={album.connection.id}">{artist.name}</a>
{#if index < album.artists.length - 1}
<span class="mr-0.5 text-sm">,</span>
{/if}
{/each}
</div>
{/if}
</div>
</section>
{#each items as item}
<div>{item.name}</div>
{/each}
{/await}
</main>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'
import { queue } from '$lib/stores' import { queue } from '$lib/stores'
import type { PageServerData } from './$types' import type { PageServerData } from './$types'
@@ -24,7 +25,11 @@
<button <button
id="searchResult" id="searchResult"
on:click={() => { on:click={() => {
if (searchResult.type === 'song') queueRef.current = searchResult if (searchResult.type === 'song') {
queueRef.current = searchResult
} else {
goto(`/details/${searchResult.type}?id=${searchResult.id}&connection=${searchResult.connection.id}`)
}
}} }}
class="grid aspect-square h-full place-items-center bg-cover bg-center bg-no-repeat" class="grid aspect-square h-full place-items-center bg-cover bg-center bg-no-repeat"
style="--thumbnail: url('/api/remoteImage?url={'thumbnailUrl' in searchResult ? searchResult.thumbnailUrl : searchResult.profilePicture}')" style="--thumbnail: url('/api/remoteImage?url={'thumbnailUrl' in searchResult ? searchResult.thumbnailUrl : searchResult.profilePicture}')"

View File

@@ -31,7 +31,7 @@ export const actions: Actions = {
const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, serverUrl: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } }) const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, serverUrl: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } })
const response = await fetch(`/api/connections?ids=${newConnectionId}`, { const response = await fetch(`/api/connections?id=${newConnectionId}`, {
method: 'GET', method: 'GET',
headers: { apikey: SECRET_INTERNAL_API_KEY }, headers: { apikey: SECRET_INTERNAL_API_KEY },
}).then((response) => { }).then((response) => {
@@ -57,7 +57,7 @@ export const actions: Actions = {
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
}) })
const response = await fetch(`/api/connections?ids=${newConnectionId}`, { const response = await fetch(`/api/connections?id=${newConnectionId}`, {
method: 'GET', method: 'GET',
headers: { apikey: SECRET_INTERNAL_API_KEY }, headers: { apikey: SECRET_INTERNAL_API_KEY },
}) })

View File

@@ -5,26 +5,20 @@ export const GET: RequestHandler = async ({ url, request }) => {
const connectionId = url.searchParams.get('connection') const connectionId = url.searchParams.get('connection')
const id = url.searchParams.get('id') const id = url.searchParams.get('id')
if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 }) if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
// Might want to re-evaluate how specific I make these ^ v error response messages
const connection = Connections.getConnection(connectionId)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const range = request.headers.get('range') const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' })
const connection = Connections.getConnections([connectionId])[0]
const fetchStream = async (): Promise<Response> => { const response = await connection
const MAX_TRIES = 5 .getAudioStream(id, audioRequestHeaders)
let tries = 0 // * Withing the .getAudioStream() method of connections, a TypeError should be thrown if the request was invalid (e.g. non-existent id)
while (tries < MAX_TRIES) { // * A standard Error should be thrown if the fetch to the service's server failed or the request returned invalid data
++tries .catch((error: TypeError | Error) => {
const stream = await connection.getAudioStream(id, range).catch((reason) => { if (error instanceof TypeError) return new Response('Malformed Request', { status: 400 })
console.error(`Audio stream fetch failed: ${reason}`) return new Response('Failed to fetch valid audio stream', { status: 502 })
return null })
})
if (!stream || !stream.ok) continue
return stream return response
}
throw new Error(`Audio stream fetch to connection: ${connection.id} of id ${id} failed`)
}
return await fetchStream()
} }

View File

@@ -2,16 +2,21 @@ import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',') const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
if (!ids) return new Response('Missing ids query parameter', { status: 400 }) if (!ids) return new Response('Missing id query parameter', { status: 400 })
const connections: ConnectionInfo[] = [] const connections = (
for (const connection of Connections.getConnections(ids)) { await Promise.all(
await connection ids.map((id) =>
.getConnectionInfo() Connections.getConnection(id)
.then((info) => connections.push(info)) ?.getConnectionInfo()
.catch((reason) => console.log(`Failed to fetch connection info: ${reason}`)) .catch((reason) => {
} console.error(`Failed to fetch connection info: ${reason}`)
return undefined
}),
),
)
).filter((connection): connection is ConnectionInfo => connection?.id !== undefined)
return Response.json({ connections }) return Response.json({ connections })
} }

View File

@@ -0,0 +1,16 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ params, url }) => {
const connectionId = params.connectionId!
const connection = Connections.getConnection(connectionId)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const albumId = url.searchParams.get('id')
if (!albumId) return new Response(`Missing id search parameter`, { status: 400 })
const album = await connection.getAlbum(albumId).catch(() => undefined)
if (!album) return new Response(`Failed to fetch album with id: ${albumId}`, { status: 400 })
return Response.json({ album })
}

View File

@@ -0,0 +1,13 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ params }) => {
const { connectionId, albumId } = params
const connection = Connections.getConnection(connectionId!)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const items = await connection.getAlbumItems(albumId!).catch(() => undefined)
if (!items) return new Response(`Failed to fetch album with id: ${albumId!}`, { status: 400 })
return Response.json({ items })
}

View File

@@ -0,0 +1,16 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ params, url }) => {
const connectionId = params.connectionId!
const connection = Connections.getConnection(connectionId)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const playlistId = url.searchParams.get('id')
if (!playlistId) return new Response(`Missing id search parameter`, { status: 400 })
const playlist = await connection.getPlaylist(playlistId).catch(() => undefined)
if (!playlist) return new Response(`Failed to fetch playlist with id: ${playlistId}`, { status: 400 })
return Response.json({ playlist })
}

View File

@@ -0,0 +1,13 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ params }) => {
const { connectionId, playlistId } = params
const connection = Connections.getConnection(connectionId!)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const items = await connection.getPlaylistItems(playlistId!).catch((reason) => console.error(reason))
if (!items) return new Response(`Failed to fetch playlist with id: ${playlistId!}`, { status: 400 })
return Response.json({ items })
}

View File

@@ -11,19 +11,16 @@ export const GET: RequestHandler = async ({ url }) => {
let tries = 0 let tries = 0
while (tries < MAX_TRIES) { while (tries < MAX_TRIES) {
++tries ++tries
const response = await fetch(imageUrl).catch((reason) => { const response = await fetch(imageUrl).catch(() => null)
console.error(`Image fetch to ${imageUrl} failed: ${reason}`)
return null
})
if (!response || !response.ok) continue if (!response || !response.ok) continue
const contentType = response.headers.get('content-type') const contentType = response.headers.get('content-type')
if (!contentType || !contentType.startsWith('image')) throw new Error(`Url ${imageUrl} does not link to an image`) if (!contentType || !contentType.startsWith('image')) throw Error(`Url ${imageUrl} does not link to an image`)
return response return response
} }
throw new Error('Exceed Max Retires') throw Error(`Failed to fetch image at ${url} Exceed Max Retires`)
} }
return await fetchImage() return await fetchImage()

View File

@@ -2,18 +2,27 @@ import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('query') const { query, userId, filter } = Object.fromEntries(url.searchParams) as { [k: string]: string | undefined }
if (!query) return new Response('Missing query parameter', { status: 400 }) if (!(query && userId)) return new Response('Missing search parameter', { status: 400 })
const userId = url.searchParams.get('userId')
if (!userId) return new Response('Missing userId parameter', { status: 400 })
const searchResults: (Song | Album | Artist | Playlist)[] = [] const userConnections = Connections.getUserConnections(userId)
for (const connection of Connections.getUserConnections(userId)) { if (!userConnections) return new Response('Invalid user id', { status: 400 })
await connection
.search(query) let checkedFilter: 'song' | 'album' | 'artist' | 'playlist' | undefined
.then((results) => searchResults.push(...results)) if (filter === 'song' || filter === 'album' || filter === 'artist' || filter === 'playlist') checkedFilter = filter
.catch((reason) => console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`))
} const searchResults = (
await Promise.all(
userConnections.map((connection) =>
connection.search(query, checkedFilter).catch((reason) => {
console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)
return undefined
}),
),
)
)
.flat()
.filter((result): result is Song | Album | Artist | Playlist => result?.id !== undefined)
return Response.json({ searchResults }) return Response.json({ searchResults })
} }

View File

@@ -4,13 +4,19 @@ import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userId = params.userId!
const connections: ConnectionInfo[] = [] const userConnections = Connections.getUserConnections(userId)
for (const connection of Connections.getUserConnections(userId)) { if (!userConnections) return new Response('Invalid user id', { status: 400 })
await connection
.getConnectionInfo() const connections = (
.then((info) => connections.push(info)) await Promise.all(
.catch((reason) => console.log(`Failed to fetch connection info: ${reason}`)) userConnections.map((connection) =>
} connection.getConnectionInfo().catch((reason) => {
console.log(`Failed to fetch connection info: ${reason}`)
return undefined
}),
),
)
).filter((info): info is ConnectionInfo => info !== undefined)
return Response.json({ connections }) return Response.json({ connections })
} }

View File

@@ -6,13 +6,21 @@ import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userId = params.userId!
const recommendations: (Song | Album | Artist | Playlist)[] = [] const userConnections = Connections.getUserConnections(userId)
for (const connection of Connections.getUserConnections(userId)) { if (!userConnections) return new Response('Invalid user id', { status: 400 })
await connection
.getRecommendations() const recommendations = (
.then((connectionRecommendations) => recommendations.push(...connectionRecommendations)) await Promise.all(
.catch((reason) => console.log(`Failed to fetch recommendations: ${reason}`)) userConnections.map((connection) =>
} connection.getRecommendations().catch((reason) => {
console.log(`Failed to fetch recommendations: ${reason}`)
return undefined
}),
),
)
)
.flat()
.filter((recommendation): recommendation is Song | Album | Artist | Playlist => recommendation?.id !== undefined)
return Response.json({ recommendations }) return Response.json({ recommendations })
} }