Dropped ytdl, YT audio now fetched with Android Client. Began work on YT Premium support
This commit is contained in:
45
package-lock.json
generated
45
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
54
src/app.d.ts
vendored
@@ -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 {}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
214
src/lib/server/youtube-music-types.d.ts
vendored
214
src/lib/server/youtube-music-types.d.ts
vendored
@@ -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
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
23
src/routes/(app)/details/album/+page.server.ts
Normal file
23
src/routes/(app)/details/album/+page.server.ts
Normal 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()]) }
|
||||
}
|
||||
34
src/routes/(app)/details/album/+page.svelte
Normal file
34
src/routes/(app)/details/album/+page.svelte
Normal 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>
|
||||
@@ -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}')"
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
16
src/routes/api/connections/[connectionId]/album/+server.ts
Normal file
16
src/routes/api/connections/[connectionId]/album/+server.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user