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']
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 })
}
}

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
const { userId, type, service, tokens } = result
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
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,92 +210,153 @@ export namespace InnerTube {
}
}
interface AlbumResponse {
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: [
{
musicShelfRenderer: {
contents: Array<{
musicResponsiveListItemRenderer: {
flexColumns: Array<{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs?: [
{
text: string
navigationEndpoint?: {
watchEndpoint: watchEndpoint
}
},
]
}
namespace Album {
interface AlbumResponse {
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: [
{
musicShelfRenderer: {
contents: Array<AlbumItem>
continuations?: [
{
nextContinuationData: {
continuation: string
}
}>
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: {
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 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,114 +108,207 @@ 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: {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${year + month + day}.01.00`,
hl: 'en',
},
const context = {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${year + month + day}.01.00`,
hl: 'en',
},
}
switch (requestDetails.type) {
case 'browse':
url = url.concat('browse')
body['browseId'] = requestDetails.browseId
break
case 'search':
url = url.concat('search')
if (requestDetails.filter) body['params'] = searchFilterParams[requestDetails.filter]
body['query'] = requestDetails.searchTerm
break
case 'continuation':
url = url.concat(`browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`)
break
const fetchData = (): [URL, Object] => {
switch (requestDetails.type) {
case 'browse':
return [
new URL('https://music.youtube.com/youtubei/v1/browse'),
{
browseId: requestDetails.browseId,
context,
},
]
case 'search':
return [
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, {
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)[] = []
const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists']
for (const section of contents) {
if ('musicCardShelfRenderer' in section) {
parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer))
section.musicCardShelfRenderer.contents?.forEach((item) => {
if ('musicResponsiveListItemRenderer' in item) {
parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
}
})
continue
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) => {
if ('musicResponsiveListItemRenderer' in item) {
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
if (!goodSections.includes(sectionType)) continue
const parsedSectionContents = section.musicShelfRenderer.contents.map((item) => parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
parsedSearchResults.push(...parsedSectionContents)
return this.scrapedToMediaItems(parsedSearchResults)
} catch (error) {
console.log(error)
console.log(JSON.stringify(contents))
throw Error('Something fucked up')
}
return await this.scrapedToMediaItems(parsedSearchResults)
}
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
const scrapedRecommendations: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = []
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
for (const section of contents) {
const sectionType = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text
if (!goodSections.includes(sectionType)) continue
try {
const scrapedRecommendations: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = []
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
const parsedContent = section.musicCarouselShelfRenderer.contents.map((content) =>
'musicTwoRowItemRenderer' in content ? parseTwoRowItemRenderer(content.musicTwoRowItemRenderer) : parseResponsiveListItemRenderer(content.musicResponsiveListItemRenderer),
)
scrapedRecommendations.push(...parsedContent)
const parsedContent = section.musicCarouselShelfRenderer.contents.map((content) =>
'musicTwoRowItemRenderer' in content ? parseTwoRowItemRenderer(content.musicTwoRowItemRenderer) : parseResponsiveListItemRenderer(content.musicResponsiveListItemRenderer),
)
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> {
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) => {
// 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?)
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
// 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 id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
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
})
if (!stream || !stream.ok) continue
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 })
})
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 })
}