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",
"node-vibrant": "^3.2.1-alpha.1",
"pocketbase": "^0.21.1",
"type-fest": "^4.12.0",
"ytdl-core": "^4.11.5",
"zod": "^3.22.4"
"type-fest": "^4.12.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
@@ -2885,18 +2883,6 @@
"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": {
"version": "0.30.5",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
@@ -2995,14 +2981,6 @@
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -5130,27 +5108,6 @@
"engines": {
"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",
"node-vibrant": "^3.2.1-alpha.1",
"pocketbase": "^0.21.1",
"type-fest": "^4.12.0",
"ytdl-core": "^4.11.5",
"zod": "^3.22.4"
"type-fest": "^4.12.0"
}
}

54
src/app.d.ts vendored
View File

@@ -40,12 +40,52 @@ declare global {
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 {
public id: string
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, range: string | null) => Promise<Response>
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
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.
@@ -84,6 +124,7 @@ declare global {
isVideo: boolean
}
// Properties like duration and track count are properties of album items not the album itself
type Album = {
connection: {
id: string
@@ -92,15 +133,13 @@ declare global {
id: string
name: string
type: 'album'
duration?: number // Seconds
thumbnailUrl: string
artists: { // Should try to order
id: string
name: string
profilePicture?: string
}[] | 'Various Artists'
releaseDate?: string // ISOString
length?: number
releaseYear?: string // ####
}
// Need to figure out how to do Artists, maybe just query MusicBrainz?
@@ -129,8 +168,9 @@ declare global {
name: string
profilePicture?: string
}
length: number
}
type HasDefinedProperty<T, K extends keyof T> = T & { [P in K]-?: Exclude<T[P], undefined> };
}
export {}

View File

@@ -8,8 +8,23 @@ export const handle: Handle = async ({ event, resolve }) => {
if (urlpath.startsWith('/api')) {
const unprotectedAPIRoutes = ['/api/audio', '/api/remoteImage']
function checkAuthorization(): boolean {
const apikey = event.request.headers.get('apikey') || event.url.searchParams.get('apikey')
if (!unprotectedAPIRoutes.includes(urlpath) && apikey !== SECRET_INTERNAL_API_KEY) {
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 })
}
}

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 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>
<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}
<img
bind:this={image}
@@ -30,7 +39,15 @@
<IconButton
halo={true}
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" />
@@ -44,10 +61,9 @@
{#if mediaItem.artists === 'Various Artists'}
<span class="text-sm">Various Artists</span>
{:else}
{#each mediaItem.artists as artist}
{@const listIndex = mediaItem.artists.indexOf(artist)}
{#each mediaItem.artists as artist, index}
<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>
{/if}
{/each}

View File

@@ -4,6 +4,7 @@
import { queue } from '$lib/stores'
// import { FastAverageColor } from 'fast-average-color'
import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.svelte'
$: currentlyPlaying = $queue.current
@@ -16,6 +17,10 @@
let volume: number,
muted = false
const maxVolume = 0.5
let waiting: boolean
$: muted ? (volume = 0) : (volume = Number(localStorage.getItem('volume')))
$: if (volume && !muted) localStorage.setItem('volume', volume.toString())
@@ -45,7 +50,7 @@
if (storedVolume) {
volume = Number(storedVolume)
} else {
localStorage.setItem('volume', '0.5')
localStorage.setItem('volume', (maxVolume / 2).toString())
volume = 0.5
}
@@ -76,6 +81,14 @@
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
$: 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>
{#if currentlyPlaying}
@@ -90,7 +103,7 @@
</div>
<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.uploader?.name}</div>
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') ?? currentlyPlaying.uploader?.name}</div>
</section>
</section>
<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()}>
<i class="fa-solid fa-backward-step" />
</button>
<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 on:click={() => (paused = !paused)} class="relative grid aspect-square h-8 place-items-center rounded-full bg-white text-black">
{#if waiting}
<Loader size={1} />
{:else}
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
{/if}
</button>
<button class="aspect-square h-8" on:click={() => $queue.next()}>
<i class="fa-solid fa-forward-step" />
@@ -133,10 +150,10 @@
<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">
<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>
<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>
<button class="aspect-square h-8" on:click={() => (expanded = true)}>
@@ -148,30 +165,32 @@
</section>
</main>
{: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="relative">
{#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="" />
{/key}
</section>
<section class="flex flex-col gap-2">
<div class="ml-2 text-2xl">Up next</div>
{#each $queue.list as item, index}
<section class="no-scrollbar flex max-h-full flex-col gap-3 overflow-y-scroll">
<strong class="ml-2 text-2xl">UP NEXT</strong>
{#each $queue.list as item}
{@const isCurrent = item === currentlyPlaying}
<button
on:click={() => {
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>
<img class="justify-self-center" src="/api/remoteImage?url={item.thumbnailUrl}" alt="" draggable="false" />
<div class="h-20 w-20 bg-cover bg-center" style="background-image: url('/api/remoteImage?url={item.thumbnailUrl}');" />
<div class="justify-items-left text-left">
<div class="line-clamp-2">{item.name}</div>
<div class="mt-[.15rem] text-neutral-500">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div>
<div class="line-clamp-1">{item.name}</div>
<div class="mt-[.15rem] line-clamp-1 text-neutral-400">{item.artists?.map((artist) => artist.name).join(', ') ?? item.uploader?.name}</div>
</div>
<span class="text-right">{formatTime(item.duration)}</span>
<span class="mr-4 text-right">{formatTime(item.duration)}</span>
</button>
{/each}
</section>
@@ -194,10 +213,42 @@
<span bind:this={expandedDurationTimestamp} class="text-left" />
</div>
<div class="expanded-controls">
<div>
<div class="mb-2 line-clamp-2 text-3xl">{currentlyPlaying.name}</div>
<div class="line-clamp-1 text-lg">
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}{currentlyPlaying.album ? ` - ${currentlyPlaying.album.name}` : ''}
<div class="flex flex-col gap-2 overflow-hidden">
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-9 w-full">
<strong
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 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()}>
<i class="fa-solid fa-backward-step" />
</button>
<button on:click={() => (paused = !paused)} class="grid aspect-square h-16 place-items-center rounded-full bg-white">
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-black" />
<button on:click={() => (paused = !paused)} class="relative grid aspect-square h-16 place-items-center rounded-full bg-white text-black">
{#if waiting}
<Loader size={2.5} />
{:else}
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
{/if}
</button>
<button class="aspect-square h-16" on:click={() => $queue.next()}>
<i class="fa-solid fa-forward-step" />
@@ -220,10 +275,10 @@
<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">
<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>
<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>
<button class="aspect-square h-8" on:click={() => (expanded = false)}>
@@ -237,18 +292,27 @@
</section>
</main>
{/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>
{/if}
<style>
.expanded-player {
display: grid;
grid-template-rows: auto 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;
grid-template-rows: calc(100% - 12rem) 12rem;
}
.song-queue-wrapper {
display: grid;
@@ -257,10 +321,7 @@
}
.queue-item {
display: grid;
grid-template-columns: 1rem 50px auto min-content;
}
.queue-item:hover {
background-color: rgba(64, 64, 64, 0.5);
grid-template-columns: 5rem auto min-content;
}
.progress-bar-expanded {
display: grid;
@@ -270,6 +331,44 @@
}
.expanded-controls {
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>

View File

@@ -1,6 +1,9 @@
<!-- 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>
#loader:after {

View File

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

View File

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

View File

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

View File

@@ -210,6 +210,7 @@ export namespace InnerTube {
}
}
namespace Album {
interface AlbumResponse {
contents: {
singleColumnBrowseResultsRenderer: {
@@ -221,38 +222,15 @@ export namespace InnerTube {
contents: [
{
musicShelfRenderer: {
contents: Array<{
musicResponsiveListItemRenderer: {
flexColumns: Array<{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs?: [
contents: Array<AlbumItem>
continuations?: [
{
text: string
navigationEndpoint?: {
watchEndpoint: watchEndpoint
nextContinuationData: {
continuation: string
}
},
]
}
}
}>
fixedColumns: [
{
musicResponsiveListItemFixedColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
]
}
}>
}
},
]
}
@@ -296,6 +274,89 @@ export namespace InnerTube {
}
}
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
},
]
}
}
},
]
}
}
type ContentShelf = {
contents: Array<AlbumItem>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
interface ContinuationResponse {
continuationContents: {
musicShelfContinuation: ContentShelf
}
}
}
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 {
contents: {
tabbedSearchResultsRenderer: {
@@ -312,6 +373,9 @@ export namespace InnerTube {
| {
musicShelfRenderer: musicShelfRenderer
}
| {
itemSectionRenderer: unknown
}
>
}
}
@@ -397,7 +461,7 @@ export namespace InnerTube {
title: {
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 ytdl from 'ytdl-core'
import { DB } from './db'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
@@ -56,22 +55,7 @@ export class YouTubeMusic implements Connection {
this.expiry = expiry
}
private get innertubeRequestHeaders() {
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()}`,
})
})()
}
// 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
private get accessToken() {
return (async () => {
const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => {
@@ -124,56 +108,83 @@ export class YouTubeMusic implements Connection {
}
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 year = currentDate.getUTCFullYear().toString()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0')
let url = 'https://music.youtube.com/youtubei/v1/'
const body: { [k: string]: any } = {
context: {
const context = {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${year + month + day}.01.00`,
hl: 'en',
},
},
}
const fetchData = (): [URL, Object] => {
switch (requestDetails.type) {
case 'browse':
url = url.concat('browse')
body['browseId'] = requestDetails.browseId
break
return [
new URL('https://music.youtube.com/youtubei/v1/browse'),
{
browseId: requestDetails.browseId,
context,
},
]
case 'search':
url = url.concat('search')
if (requestDetails.filter) body['params'] = searchFilterParams[requestDetails.filter]
body['query'] = requestDetails.searchTerm
break
return [
new URL('https://music.youtube.com/youtubei/v1/search'),
{
query: requestDetails.searchTerm,
filter: requestDetails.filter ? searchFilterParams[requestDetails.filter] : undefined,
context,
},
]
case 'continuation':
url = url.concat(`browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`)
break
return [
new URL(`https://music.youtube.com/youtubei/v1/browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`),
{
context,
},
]
}
}
return fetch(url, {
headers: await this.innertubeRequestHeaders,
method: 'POST',
body: JSON.stringify(body),
}).then((response) => response.json())
const [url, body] = fetchData()
return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) })
}
// 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)[]> {
// Figure out how to handle Library and Uploads
// 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 parsedSearchResults: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = []
try {
const parsedSearchResults = []
const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists']
for (const section of contents) {
if ('itemSectionRenderer' in section) continue
if ('musicCardShelfRenderer' in section) {
parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer))
section.musicCardShelfRenderer.contents?.forEach((item) => {
@@ -191,16 +202,23 @@ export class YouTubeMusic implements Connection {
parsedSearchResults.push(...parsedSectionContents)
}
return await this.scrapedToMediaItems(parsedSearchResults)
return this.scrapedToMediaItems(parsedSearchResults)
} catch (error) {
console.log(error)
console.log(JSON.stringify(contents))
throw Error('Something fucked up')
}
}
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
const homeResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' })) as InnerTube.HomeResponse
// TODO: Figure out why this still breaks sometimes
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
try {
const scrapedRecommendations: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = []
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library', 'Recommended music videos', 'Recommended albums']
for (const section of contents) {
const sectionType = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text
if (!goodSections.includes(sectionType)) continue
@@ -211,27 +229,86 @@ export class YouTubeMusic implements Connection {
scrapedRecommendations.push(...parsedContent)
}
return await this.scrapedToMediaItems(scrapedRecommendations)
return this.scrapedToMediaItems(scrapedRecommendations)
} catch (error) {
console.log(error)
console.log(JSON.stringify(contents))
throw Error('Something fucked up')
}
}
public async getAudioStream(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' })
public async getAudioStream(id: string, headers: Headers): Promise<Response> {
if (!/^[a-zA-Z0-9-_]{11}$/.test(id)) throw TypeError('Invalid youtube video ID')
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
* @returns Basic info about the album in the Album type schema.
*/
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 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 name = header.title.runs[0].text,
@@ -249,23 +326,82 @@ export class YouTubeMusic implements Connection {
}
}
const releaseDate = header.subtitle.runs.at(-1)?.text!,
length = contents.length
const releaseYear = header.subtitle.runs.at(-1)?.text!
const duration = contents.reduce(
(accumulator, current) => (accumulator += timestampToSeconds(current.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)),
0,
)
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
}
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!).
* @returns Basic info about the playlist in the Playlist type schema.
*/
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 =
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.header
@@ -276,7 +412,7 @@ export class YouTubeMusic implements Connection {
const name = header.title.runs[0].text
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']
@@ -284,61 +420,84 @@ export class YouTubeMusic implements Connection {
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"
const length = Number(trackCountText.split(' ')[0])
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy, length } satisfies Playlist
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } 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[]> {
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
let continuation =
playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.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)
continuation = continuationResponse.continuationContents.musicPlaylistShelfContinuation.continuations?.[0].nextContinuationData.continuation
}
const playlistItems: InnerTube.ScrapedSong[] = []
contents.forEach((item) => {
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)
// 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 playableItems = contents.filter((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined)
const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
const scrapedItems = playableItems.map((item) => {
const [col0, col1, col2] = 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 videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
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 album: Song['album'] = col2run ? { id: col2run.navigationEndpoint.browseEndpoint.browseId, name: col2run.text } : undefined
let artists: Song['artists'] = [],
uploader: Song['uploader']
let artists: { id?: string; name: string }[] | undefined = [],
uploader: { id?: string; name: string } | undefined
for (const run of col1.musicResponsiveListItemFlexColumnRenderer.text.runs) {
if (!run.navigationEndpoint) continue
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)
}
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]>[]> {
@@ -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 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 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>(),
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))
playlistDetails.forEach((playlist) => playlistDetailsMap.set(playlist.id, playlist))
@@ -394,18 +561,11 @@ export class YouTubeMusic implements Connection {
const thumbnails = songDetails.snippet?.thumbnails!
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 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
case 'album':
return albumDetailsMap.get(item.id)! satisfies Album
@@ -444,8 +604,7 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
if ('watchEndpoint' in rowContent.navigationEndpoint) {
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
const musicVideoType = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = musicVideoType === 'MUSIC_VIDEO_TYPE_UGC' || musicVideoType === 'MUSIC_VIDEO_TYPE_OMV'
const isVideo = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl: InnerTube.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
let albumId: string | undefined
@@ -476,6 +635,8 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
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)) {
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 = musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const isVideo =
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 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
case 'MUSIC_PAGE_TYPE_PLAYLIST':
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
if ('watchEndpoint' in navigationEndpoint) {
const id = navigationEndpoint.watchEndpoint.videoId
const musicVideoType = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const isVideo = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
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
@@ -582,6 +745,8 @@ function parseMusicCardShelfRenderer(cardContent: InnerTube.musicCardShelfRender
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
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(':')
.reverse()
.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)
}
public setQueue(...songs: Song[]) {
this.songs = songs
this.currentPosition = songs.length === 0 ? -1 : 0
writableQueue.set(this)
}
public clear() {
this.currentPosition = -1
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">
import { goto } from '$app/navigation'
import { queue } from '$lib/stores'
import type { PageServerData } from './$types'
@@ -24,7 +25,11 @@
<button
id="searchResult"
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"
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 response = await fetch(`/api/connections?ids=${newConnectionId}`, {
const response = await fetch(`/api/connections?id=${newConnectionId}`, {
method: 'GET',
headers: { apikey: SECRET_INTERNAL_API_KEY },
}).then((response) => {
@@ -57,7 +57,7 @@ export const actions: Actions = {
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',
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 id = url.searchParams.get('id')
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 connection = Connections.getConnections([connectionId])[0]
const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' })
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
const response = await connection
.getAudioStream(id, audioRequestHeaders)
// * Withing the .getAudioStream() method of connections, a TypeError should be thrown if the request was invalid (e.g. non-existent id)
// * A standard Error should be thrown if the fetch to the service's server failed or the request returned invalid data
.catch((error: TypeError | Error) => {
if (error instanceof TypeError) return new Response('Malformed Request', { status: 400 })
return new Response('Failed to fetch valid audio stream', { status: 502 })
})
if (!stream || !stream.ok) continue
return stream
}
throw new Error(`Audio stream fetch to connection: ${connection.id} of id ${id} failed`)
}
return await fetchStream()
return response
}

View File

@@ -2,16 +2,21 @@ import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ url }) => {
const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',')
if (!ids) return new Response('Missing ids query parameter', { status: 400 })
const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
if (!ids) return new Response('Missing id query parameter', { status: 400 })
const connections: ConnectionInfo[] = []
for (const connection of Connections.getConnections(ids)) {
await connection
.getConnectionInfo()
.then((info) => connections.push(info))
.catch((reason) => console.log(`Failed to fetch connection info: ${reason}`))
}
const connections = (
await Promise.all(
ids.map((id) =>
Connections.getConnection(id)
?.getConnectionInfo()
.catch((reason) => {
console.error(`Failed to fetch connection info: ${reason}`)
return undefined
}),
),
)
).filter((connection): connection is ConnectionInfo => connection?.id !== undefined)
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
while (tries < MAX_TRIES) {
++tries
const response = await fetch(imageUrl).catch((reason) => {
console.error(`Image fetch to ${imageUrl} failed: ${reason}`)
return null
})
const response = await fetch(imageUrl).catch(() => 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`)
if (!contentType || !contentType.startsWith('image')) throw Error(`Url ${imageUrl} does not link to an image`)
return response
}
throw new Error('Exceed Max Retires')
throw Error(`Failed to fetch image at ${url} Exceed Max Retires`)
}
return await fetchImage()

View File

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

View File

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

View File

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