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",
|
"musicbrainz-api": "^0.15.0",
|
||||||
"node-vibrant": "^3.2.1-alpha.1",
|
"node-vibrant": "^3.2.1-alpha.1",
|
||||||
"pocketbase": "^0.21.1",
|
"pocketbase": "^0.21.1",
|
||||||
"type-fest": "^4.12.0",
|
"type-fest": "^4.12.0"
|
||||||
"ytdl-core": "^4.11.5",
|
|
||||||
"zod": "^3.22.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
@@ -2885,18 +2883,6 @@
|
|||||||
"node": "14 || >=16.14"
|
"node": "14 || >=16.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/m3u8stream": {
|
|
||||||
"version": "0.8.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/m3u8stream/-/m3u8stream-0.8.6.tgz",
|
|
||||||
"integrity": "sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA==",
|
|
||||||
"dependencies": {
|
|
||||||
"miniget": "^4.2.2",
|
|
||||||
"sax": "^1.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.5",
|
"version": "0.30.5",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
|
||||||
@@ -2995,14 +2981,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/miniget": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/miniget/-/miniget-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -5130,27 +5108,6 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/ytdl-core": {
|
|
||||||
"version": "4.11.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/ytdl-core/-/ytdl-core-4.11.5.tgz",
|
|
||||||
"integrity": "sha512-27LwsW4n4nyNviRCO1hmr8Wr5J1wLLMawHCQvH8Fk0hiRqrxuIu028WzbJetiYH28K8XDbeinYW4/wcHQD1EXA==",
|
|
||||||
"dependencies": {
|
|
||||||
"m3u8stream": "^0.8.6",
|
|
||||||
"miniget": "^4.2.2",
|
|
||||||
"sax": "^1.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
|
||||||
"version": "3.22.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
|
|
||||||
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,6 @@
|
|||||||
"musicbrainz-api": "^0.15.0",
|
"musicbrainz-api": "^0.15.0",
|
||||||
"node-vibrant": "^3.2.1-alpha.1",
|
"node-vibrant": "^3.2.1-alpha.1",
|
||||||
"pocketbase": "^0.21.1",
|
"pocketbase": "^0.21.1",
|
||||||
"type-fest": "^4.12.0",
|
"type-fest": "^4.12.0"
|
||||||
"ytdl-core": "^4.11.5",
|
|
||||||
"zod": "^3.22.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/app.d.ts
vendored
54
src/app.d.ts
vendored
@@ -40,12 +40,52 @@ declare global {
|
|||||||
profilePicture?: string
|
profilePicture?: string
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type SearchFilterMap<Filter> =
|
||||||
|
Filter extends 'song' ? Song :
|
||||||
|
Filter extends 'album' ? Album :
|
||||||
|
Filter extends 'artist' ? Artist :
|
||||||
|
Filter extends 'playlist' ? Playlist :
|
||||||
|
Filter extends undefined ? Song | Album | Artist | Playlist :
|
||||||
|
never
|
||||||
|
|
||||||
interface Connection {
|
interface Connection {
|
||||||
public id: string
|
public id: string
|
||||||
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
|
|
||||||
getConnectionInfo: () => Promise<ConnectionInfo>
|
getConnectionInfo: () => Promise<ConnectionInfo>
|
||||||
search: (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist') => Promise<(Song | Album | Artist | Playlist)[]>
|
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
|
||||||
getAudioStream: (id: string, range: string | null) => Promise<Response>
|
search: <T extends 'song' | 'album' | 'artist' | 'playlist'>(searchTerm: string, filter?: T) => Promise<SearchFilterMap<T>[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id The id of the requested song
|
||||||
|
* @param headers The request headers sent by the Lazuli client that need to be relayed to the connection's request to the server (e.g. 'range').
|
||||||
|
* @returns A promise of response object containing the audio stream for the specified byte range
|
||||||
|
*
|
||||||
|
* Fetches the audio stream for a song.
|
||||||
|
*/
|
||||||
|
getAudioStream: (id: string, headers: Headers) => Promise<Response>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id The id of an album
|
||||||
|
* @returns A promise of the album as an Album object
|
||||||
|
*/
|
||||||
|
getAlbum: (id: string) => Promise<Album>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id The id of an album
|
||||||
|
* @returns A promise of the songs in the album as and array of Song objects
|
||||||
|
*/
|
||||||
|
getAlbumItems: (id: string) => Promise<Song[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id The id of a playlist
|
||||||
|
* @returns A promise of the playlist of as a Playlist object
|
||||||
|
*/
|
||||||
|
getPlaylist: (id: string) => Promise<Playlist>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id The id of a playlist
|
||||||
|
* @returns A promise of the songs in the playlist as and array of Song objects
|
||||||
|
*/
|
||||||
|
getPlaylistItems: (id: string) => Promise<Song[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
// These Schemas should only contain general info data that is necessary for data fetching purposes.
|
// These Schemas should only contain general info data that is necessary for data fetching purposes.
|
||||||
@@ -84,6 +124,7 @@ declare global {
|
|||||||
isVideo: boolean
|
isVideo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Properties like duration and track count are properties of album items not the album itself
|
||||||
type Album = {
|
type Album = {
|
||||||
connection: {
|
connection: {
|
||||||
id: string
|
id: string
|
||||||
@@ -92,15 +133,13 @@ declare global {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'album'
|
type: 'album'
|
||||||
duration?: number // Seconds
|
|
||||||
thumbnailUrl: string
|
thumbnailUrl: string
|
||||||
artists: { // Should try to order
|
artists: { // Should try to order
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
profilePicture?: string
|
profilePicture?: string
|
||||||
}[] | 'Various Artists'
|
}[] | 'Various Artists'
|
||||||
releaseDate?: string // ISOString
|
releaseYear?: string // ####
|
||||||
length?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to figure out how to do Artists, maybe just query MusicBrainz?
|
// Need to figure out how to do Artists, maybe just query MusicBrainz?
|
||||||
@@ -129,8 +168,9 @@ declare global {
|
|||||||
name: string
|
name: string
|
||||||
profilePicture?: string
|
profilePicture?: string
|
||||||
}
|
}
|
||||||
length: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HasDefinedProperty<T, K extends keyof T> = T & { [P in K]-?: Exclude<T[P], undefined> };
|
||||||
}
|
}
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|||||||
@@ -8,8 +8,23 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
|
|
||||||
if (urlpath.startsWith('/api')) {
|
if (urlpath.startsWith('/api')) {
|
||||||
const unprotectedAPIRoutes = ['/api/audio', '/api/remoteImage']
|
const unprotectedAPIRoutes = ['/api/audio', '/api/remoteImage']
|
||||||
|
|
||||||
|
function checkAuthorization(): boolean {
|
||||||
const apikey = event.request.headers.get('apikey') || event.url.searchParams.get('apikey')
|
const apikey = event.request.headers.get('apikey') || event.url.searchParams.get('apikey')
|
||||||
if (!unprotectedAPIRoutes.includes(urlpath) && apikey !== SECRET_INTERNAL_API_KEY) {
|
if (apikey === SECRET_INTERNAL_API_KEY) return true
|
||||||
|
|
||||||
|
const authToken = event.cookies.get('lazuli-auth')
|
||||||
|
if (!authToken) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
jwt.verify(authToken, SECRET_JWT_KEY)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unprotectedAPIRoutes.includes(urlpath) && !checkAuthorization()) {
|
||||||
return new Response('Unauthorized', { status: 401 })
|
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 queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly
|
||||||
|
|
||||||
let image: HTMLImageElement, captionText: HTMLDivElement
|
let image: HTMLImageElement, captionText: HTMLDivElement
|
||||||
|
|
||||||
|
async function setQueueItems(mediaItem: Album | Playlist) {
|
||||||
|
const itemsResponse = await fetch(`/api/connections/${mediaItem.connection.id}/${mediaItem.type}/${mediaItem.id}/items`, {
|
||||||
|
credentials: 'include',
|
||||||
|
}).then((response) => response.json() as Promise<{ items: Song[] }>)
|
||||||
|
|
||||||
|
const items = itemsResponse.items
|
||||||
|
queueRef.setQueue(...items)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="card-wrapper" class="flex-shrink-0">
|
<div id="card-wrapper" class="flex-shrink-0">
|
||||||
<button id="thumbnail" class="relative h-52 transition-all duration-200 ease-out" on:click={() => goto(`/details/${mediaItem.type}?id=${mediaItem.id}`)}>
|
<button id="thumbnail" class="relative h-52 transition-all duration-200 ease-out" on:click={() => goto(`/details/${mediaItem.type}?id=${mediaItem.id}&connection=${mediaItem.connection.id}`)}>
|
||||||
{#if 'thumbnailUrl' in mediaItem || 'profilePicture' in mediaItem}
|
{#if 'thumbnailUrl' in mediaItem || 'profilePicture' in mediaItem}
|
||||||
<img
|
<img
|
||||||
bind:this={image}
|
bind:this={image}
|
||||||
@@ -30,7 +39,15 @@
|
|||||||
<IconButton
|
<IconButton
|
||||||
halo={true}
|
halo={true}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
if (mediaItem.type === 'song') queueRef.current = mediaItem
|
switch (mediaItem.type) {
|
||||||
|
case 'song':
|
||||||
|
queueRef.current = mediaItem
|
||||||
|
break
|
||||||
|
case 'album':
|
||||||
|
case 'playlist':
|
||||||
|
setQueueItems(mediaItem)
|
||||||
|
break
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<i slot="icon" class="fa-solid fa-play text-xl" />
|
<i slot="icon" class="fa-solid fa-play text-xl" />
|
||||||
@@ -44,10 +61,9 @@
|
|||||||
{#if mediaItem.artists === 'Various Artists'}
|
{#if mediaItem.artists === 'Various Artists'}
|
||||||
<span class="text-sm">Various Artists</span>
|
<span class="text-sm">Various Artists</span>
|
||||||
{:else}
|
{:else}
|
||||||
{#each mediaItem.artists as artist}
|
{#each mediaItem.artists as artist, index}
|
||||||
{@const listIndex = mediaItem.artists.indexOf(artist)}
|
|
||||||
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
|
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
|
||||||
{#if listIndex < mediaItem.artists.length - 1}
|
{#if index < mediaItem.artists.length - 1}
|
||||||
<span class="mr-0.5 text-sm">,</span>
|
<span class="mr-0.5 text-sm">,</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { queue } from '$lib/stores'
|
import { queue } from '$lib/stores'
|
||||||
// import { FastAverageColor } from 'fast-average-color'
|
// import { FastAverageColor } from 'fast-average-color'
|
||||||
import Slider from '$lib/components/util/slider.svelte'
|
import Slider from '$lib/components/util/slider.svelte'
|
||||||
|
import Loader from '$lib/components/util/loader.svelte'
|
||||||
|
|
||||||
$: currentlyPlaying = $queue.current
|
$: currentlyPlaying = $queue.current
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@
|
|||||||
let volume: number,
|
let volume: number,
|
||||||
muted = false
|
muted = false
|
||||||
|
|
||||||
|
const maxVolume = 0.5
|
||||||
|
|
||||||
|
let waiting: boolean
|
||||||
|
|
||||||
$: muted ? (volume = 0) : (volume = Number(localStorage.getItem('volume')))
|
$: muted ? (volume = 0) : (volume = Number(localStorage.getItem('volume')))
|
||||||
$: if (volume && !muted) localStorage.setItem('volume', volume.toString())
|
$: if (volume && !muted) localStorage.setItem('volume', volume.toString())
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@
|
|||||||
if (storedVolume) {
|
if (storedVolume) {
|
||||||
volume = Number(storedVolume)
|
volume = Number(storedVolume)
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('volume', '0.5')
|
localStorage.setItem('volume', (maxVolume / 2).toString())
|
||||||
volume = 0.5
|
volume = 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +81,14 @@
|
|||||||
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
|
$: if (!seeking && expandedCurrentTimeTimestamp) expandedCurrentTimeTimestamp.innerText = formatTime(currentTime)
|
||||||
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
|
$: if (!seeking && expandedProgressBar) expandedProgressBar.$set({ value: currentTime })
|
||||||
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
|
$: if (!seeking && expandedDurationTimestamp) expandedDurationTimestamp.innerText = formatTime(duration)
|
||||||
|
|
||||||
|
let slidingText: HTMLElement
|
||||||
|
let slidingTextWidth: number, slidingTextWrapperWidth: number
|
||||||
|
let scrollDirection: 1 | -1 = 1
|
||||||
|
$: scrollDistance = slidingTextWidth - slidingTextWrapperWidth
|
||||||
|
$: if (slidingText && scrollDistance > 0) slidingText.style.animationDuration = `${scrollDistance / 50}s`
|
||||||
|
|
||||||
|
let audioElement: HTMLAudioElement
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if currentlyPlaying}
|
{#if currentlyPlaying}
|
||||||
@@ -90,7 +103,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<section class="flex flex-col justify-center gap-1">
|
<section class="flex flex-col justify-center gap-1">
|
||||||
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
|
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
|
||||||
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div>
|
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') ?? currentlyPlaying.uploader?.name}</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<section class="flex min-w-max flex-col items-center justify-center gap-1">
|
<section class="flex min-w-max flex-col items-center justify-center gap-1">
|
||||||
@@ -101,8 +114,12 @@
|
|||||||
<button class="aspect-square h-8" on:click={() => $queue.previous()}>
|
<button class="aspect-square h-8" on:click={() => $queue.previous()}>
|
||||||
<i class="fa-solid fa-backward-step" />
|
<i class="fa-solid fa-backward-step" />
|
||||||
</button>
|
</button>
|
||||||
<button on:click={() => (paused = !paused)} class="grid aspect-square h-8 place-items-center rounded-full bg-white">
|
<button on:click={() => (paused = !paused)} class="relative grid aspect-square h-8 place-items-center rounded-full bg-white text-black">
|
||||||
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-black" />
|
{#if waiting}
|
||||||
|
<Loader size={1} />
|
||||||
|
{:else}
|
||||||
|
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="aspect-square h-8" on:click={() => $queue.next()}>
|
<button class="aspect-square h-8" on:click={() => $queue.next()}>
|
||||||
<i class="fa-solid fa-forward-step" />
|
<i class="fa-solid fa-forward-step" />
|
||||||
@@ -133,10 +150,10 @@
|
|||||||
<section class="flex items-center justify-end gap-2 pr-2 text-lg">
|
<section class="flex items-center justify-end gap-2 pr-2 text-lg">
|
||||||
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
||||||
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
|
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
|
||||||
<i class="fa-solid {volume > 0.5 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
||||||
</button>
|
</button>
|
||||||
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
||||||
<Slider bind:value={volume} max={1} />
|
<Slider bind:value={volume} max={maxVolume} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="aspect-square h-8" on:click={() => (expanded = true)}>
|
<button class="aspect-square h-8" on:click={() => (expanded = true)}>
|
||||||
@@ -148,30 +165,32 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
<main in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="expanded-player h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});">
|
<main in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="expanded-player relative h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});">
|
||||||
|
<img class="absolute -z-10 h-full w-full object-cover object-center blur-xl brightness-[25%]" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
|
||||||
<section class="song-queue-wrapper h-full px-24 py-20">
|
<section class="song-queue-wrapper h-full px-24 py-20">
|
||||||
<section class="relative">
|
<section class="relative">
|
||||||
{#key currentlyPlaying}
|
{#key currentlyPlaying}
|
||||||
<img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
|
<img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
|
||||||
{/key}
|
{/key}
|
||||||
</section>
|
</section>
|
||||||
<section class="flex flex-col gap-2">
|
<section class="no-scrollbar flex max-h-full flex-col gap-3 overflow-y-scroll">
|
||||||
<div class="ml-2 text-2xl">Up next</div>
|
<strong class="ml-2 text-2xl">UP NEXT</strong>
|
||||||
{#each $queue.list as item, index}
|
{#each $queue.list as item}
|
||||||
{@const isCurrent = item === currentlyPlaying}
|
{@const isCurrent = item === currentlyPlaying}
|
||||||
<button
|
<button
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
if (!isCurrent) $queue.current = item
|
if (!isCurrent) $queue.current = item
|
||||||
}}
|
}}
|
||||||
class="queue-item w-full items-center gap-3 rounded-xl p-3 {isCurrent ? 'bg-[rgba(64,_64,_64,_0.5)]' : 'bg-[rgba(10,_10,_10,_0.5)]'}"
|
class="queue-item h-20 w-full shrink-0 items-center gap-3 overflow-clip rounded-lg bg-neutral-900 {isCurrent
|
||||||
|
? 'pointer-events-none border-[1px] border-neutral-300'
|
||||||
|
: 'hover:bg-neutral-800'}"
|
||||||
>
|
>
|
||||||
<div class="justify-self-center">{index + 1}</div>
|
<div class="h-20 w-20 bg-cover bg-center" style="background-image: url('/api/remoteImage?url={item.thumbnailUrl}');" />
|
||||||
<img class="justify-self-center" src="/api/remoteImage?url={item.thumbnailUrl}" alt="" draggable="false" />
|
|
||||||
<div class="justify-items-left text-left">
|
<div class="justify-items-left text-left">
|
||||||
<div class="line-clamp-2">{item.name}</div>
|
<div class="line-clamp-1">{item.name}</div>
|
||||||
<div class="mt-[.15rem] text-neutral-500">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div>
|
<div class="mt-[.15rem] line-clamp-1 text-neutral-400">{item.artists?.map((artist) => artist.name).join(', ') ?? item.uploader?.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-right">{formatTime(item.duration)}</span>
|
<span class="mr-4 text-right">{formatTime(item.duration)}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
@@ -194,10 +213,42 @@
|
|||||||
<span bind:this={expandedDurationTimestamp} class="text-left" />
|
<span bind:this={expandedDurationTimestamp} class="text-left" />
|
||||||
</div>
|
</div>
|
||||||
<div class="expanded-controls">
|
<div class="expanded-controls">
|
||||||
<div>
|
<div class="flex flex-col gap-2 overflow-hidden">
|
||||||
<div class="mb-2 line-clamp-2 text-3xl">{currentlyPlaying.name}</div>
|
<div bind:clientWidth={slidingTextWrapperWidth} class="relative h-9 w-full">
|
||||||
<div class="line-clamp-1 text-lg">
|
<strong
|
||||||
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}{currentlyPlaying.album ? ` - ${currentlyPlaying.album.name}` : ''}
|
bind:this={slidingText}
|
||||||
|
bind:clientWidth={slidingTextWidth}
|
||||||
|
on:animationend={() => (scrollDirection *= -1)}
|
||||||
|
class="{scrollDistance > 0 ? (scrollDirection > 0 ? 'scrollLeft' : 'scrollRight') : ''} scrollingText absolute whitespace-nowrap text-3xl">{currentlyPlaying.name}</strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;">
|
||||||
|
{#if 'artists' in currentlyPlaying && currentlyPlaying.artists && currentlyPlaying.artists.length > 0}
|
||||||
|
{#each currentlyPlaying.artists as artist, index}
|
||||||
|
<a
|
||||||
|
on:click={() => (expanded = false)}
|
||||||
|
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
|
||||||
|
href="/details/artist?id={artist.id}&connection={currentlyPlaying.connection.id}">{artist.name}</a
|
||||||
|
>
|
||||||
|
{#if index < currentlyPlaying.artists.length - 1}
|
||||||
|
<span class="mr-1 text-lg">,</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{:else if 'uploader' in currentlyPlaying && currentlyPlaying.uploader}
|
||||||
|
<a
|
||||||
|
on:click={() => (expanded = false)}
|
||||||
|
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
|
||||||
|
href="/details/user?id={currentlyPlaying.uploader.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.uploader.name}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if currentlyPlaying.album}
|
||||||
|
<span class="mx-1.5 text-lg">-</span>
|
||||||
|
<a
|
||||||
|
on:click={() => (expanded = false)}
|
||||||
|
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
|
||||||
|
href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full items-center justify-center gap-2 text-2xl">
|
<div class="flex w-full items-center justify-center gap-2 text-2xl">
|
||||||
@@ -207,8 +258,12 @@
|
|||||||
<button class="aspect-square h-16" on:click={() => $queue.previous()}>
|
<button class="aspect-square h-16" on:click={() => $queue.previous()}>
|
||||||
<i class="fa-solid fa-backward-step" />
|
<i class="fa-solid fa-backward-step" />
|
||||||
</button>
|
</button>
|
||||||
<button on:click={() => (paused = !paused)} class="grid aspect-square h-16 place-items-center rounded-full bg-white">
|
<button on:click={() => (paused = !paused)} class="relative grid aspect-square h-16 place-items-center rounded-full bg-white text-black">
|
||||||
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'} text-black" />
|
{#if waiting}
|
||||||
|
<Loader size={2.5} />
|
||||||
|
{:else}
|
||||||
|
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="aspect-square h-16" on:click={() => $queue.next()}>
|
<button class="aspect-square h-16" on:click={() => $queue.next()}>
|
||||||
<i class="fa-solid fa-forward-step" />
|
<i class="fa-solid fa-forward-step" />
|
||||||
@@ -220,10 +275,10 @@
|
|||||||
<section class="flex items-center justify-end gap-2 text-xl">
|
<section class="flex items-center justify-end gap-2 text-xl">
|
||||||
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
||||||
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
|
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
|
||||||
<i class="fa-solid {volume > 0.5 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
||||||
</button>
|
</button>
|
||||||
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
||||||
<Slider bind:value={volume} max={1} />
|
<Slider bind:value={volume} max={maxVolume} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="aspect-square h-8" on:click={() => (expanded = false)}>
|
<button class="aspect-square h-8" on:click={() => (expanded = false)}>
|
||||||
@@ -237,18 +292,27 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
<audio autoplay bind:paused bind:volume bind:currentTime bind:duration on:ended={() => $queue.next()} src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}" />
|
<audio
|
||||||
|
bind:this={audioElement}
|
||||||
|
autoplay
|
||||||
|
bind:paused
|
||||||
|
bind:volume
|
||||||
|
bind:currentTime
|
||||||
|
bind:duration
|
||||||
|
on:canplay={() => (waiting = false)}
|
||||||
|
on:loadstart={() => (waiting = true)}
|
||||||
|
on:waiting={() => (waiting = true)}
|
||||||
|
on:ended={() => $queue.next()}
|
||||||
|
on:error={() => setTimeout(() => audioElement.load(), 5000)}
|
||||||
|
src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.expanded-player {
|
.expanded-player {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 12rem;
|
grid-template-rows: calc(100% - 12rem) 12rem;
|
||||||
/* background: linear-gradient(to left, rgba(16, 16, 16, 0.9), rgb(16, 16, 16)), var(--currentlyPlayingImage); */
|
|
||||||
background-repeat: no-repeat !important;
|
|
||||||
background-size: cover !important;
|
|
||||||
background-position: center !important;
|
|
||||||
}
|
}
|
||||||
.song-queue-wrapper {
|
.song-queue-wrapper {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -257,10 +321,7 @@
|
|||||||
}
|
}
|
||||||
.queue-item {
|
.queue-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1rem 50px auto min-content;
|
grid-template-columns: 5rem auto min-content;
|
||||||
}
|
|
||||||
.queue-item:hover {
|
|
||||||
background-color: rgba(64, 64, 64, 0.5);
|
|
||||||
}
|
}
|
||||||
.progress-bar-expanded {
|
.progress-bar-expanded {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -270,6 +331,44 @@
|
|||||||
}
|
}
|
||||||
.expanded-controls {
|
.expanded-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr min-content 1fr;
|
gap: 1rem;
|
||||||
|
grid-template-columns: 1fr min-content 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollingText {
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
animation-delay: 10s;
|
||||||
|
}
|
||||||
|
.scrollingText:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
.scrollLeft {
|
||||||
|
animation-name: scrollLeft;
|
||||||
|
}
|
||||||
|
.scrollRight {
|
||||||
|
animation-name: scrollRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scrollLeft {
|
||||||
|
0% {
|
||||||
|
left: 0%;
|
||||||
|
transform: translateX(0%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scrollRight {
|
||||||
|
0% {
|
||||||
|
left: 100%;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 0%;
|
||||||
|
transform: translateX(0%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<!-- Credit to https://cssloaders.github.io/ -->
|
<!-- Credit to https://cssloaders.github.io/ -->
|
||||||
|
<script lang="ts">
|
||||||
|
export let size = 5
|
||||||
|
</script>
|
||||||
|
|
||||||
<span id="loader" class="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2" />
|
<span id="loader" style="height: {size}rem; width: {size}rem;" class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#loader:after {
|
#loader:after {
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
$: trackThumb(value)
|
$: trackThumb(value)
|
||||||
onMount(() => trackThumb(value))
|
onMount(() => trackThumb(value))
|
||||||
|
|
||||||
|
const keyPressJumpIntervalCount = 20
|
||||||
|
|
||||||
const handleKeyPress = (key: string) => {
|
const handleKeyPress = (key: string) => {
|
||||||
if ((key === 'ArrowRight' || key === 'ArrowUp') && value < 1) return (value += 1)
|
if ((key === 'ArrowRight' || key === 'ArrowUp') && value < max) value = Math.min(max, value + max / keyPressJumpIntervalCount)
|
||||||
if ((key === 'ArrowLeft' || key === 'ArrowDown') && value > 0) return (value -= 1)
|
if ((key === 'ArrowLeft' || key === 'ArrowDown') && value > 0) value = Math.max(0, value - max / keyPressJumpIntervalCount) // For some reason this is kinda broken
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { DB, type ConnectionRow } from './db'
|
import { DB } from './db'
|
||||||
import { Jellyfin } from './jellyfin'
|
import { Jellyfin } from './jellyfin'
|
||||||
import { YouTubeMusic } from './youtube-music'
|
import { YouTubeMusic } from './youtube-music'
|
||||||
|
|
||||||
const constructConnection = (connectionInfo: ConnectionRow): Connection => {
|
const constructConnection = (connectionInfo: ReturnType<typeof DB.getConnectionInfo>): Connection | undefined => {
|
||||||
|
if (!connectionInfo) return undefined
|
||||||
|
|
||||||
const { id, userId, type, service, tokens } = connectionInfo
|
const { id, userId, type, service, tokens } = connectionInfo
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'jellyfin':
|
case 'jellyfin':
|
||||||
@@ -11,20 +13,15 @@ const constructConnection = (connectionInfo: ConnectionRow): Connection => {
|
|||||||
return new YouTubeMusic(id, userId, service.userId, tokens.accessToken, tokens.refreshToken, tokens.expiry)
|
return new YouTubeMusic(id, userId, service.userId, tokens.accessToken, tokens.refreshToken, tokens.expiry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function getConnection(id: string): Connection | undefined {
|
||||||
const getConnections = (ids: string[]): Connection[] => {
|
return constructConnection(DB.getConnectionInfo(id))
|
||||||
const connectionInfo = DB.getConnectionInfo(ids)
|
|
||||||
|
|
||||||
return Array.from(connectionInfo, (info) => constructConnection(info))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserConnections = (userId: string): Connection[] => {
|
const getUserConnections = (userId: string): Connection[] | undefined => {
|
||||||
const connectionInfo = DB.getUserConnectionInfo(userId)
|
return DB.getUserConnectionInfo(userId)?.map((info) => constructConnection(info)!)
|
||||||
|
|
||||||
return Array.from(connectionInfo, (info) => constructConnection(info))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Connections = {
|
export const Connections = {
|
||||||
getConnections,
|
getConnection,
|
||||||
getUserConnections,
|
getUserConnections,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,12 +67,12 @@ class Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getUser = (id: string): User | undefined => {
|
public getUser = (id: string): User | undefined => {
|
||||||
const user = this.database.prepare(`SELECT * FROM Users WHERE id = ?`).get(id) as User | undefined
|
const user = this.database.prepare(`SELECT * FROM Users WHERE id = ? LIMIT 1`).get(id) as User | undefined
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUsername = (username: string): User | undefined => {
|
public getUsername = (username: string): User | undefined => {
|
||||||
const user = this.database.prepare(`SELECT * FROM Users WHERE lower(username) = ?`).get(username.toLowerCase()) as User | undefined
|
const user = this.database.prepare(`SELECT * FROM Users WHERE lower(username) = ? LIMIT 1`).get(username.toLowerCase()) as User | undefined
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,21 +87,20 @@ class Storage {
|
|||||||
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
|
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
|
||||||
}
|
}
|
||||||
|
|
||||||
public getConnectionInfo = (ids: string[]): ConnectionRow[] => {
|
public getConnectionInfo = (id: string): ConnectionRow | undefined => {
|
||||||
const connectionInfo: ConnectionRow[] = []
|
const result = this.database.prepare(`SELECT * FROM Connections WHERE id = ? LIMIT 1`).get(id) as DBConnectionsTableSchema | undefined
|
||||||
for (const id of ids) {
|
if (!result) return undefined
|
||||||
const result = this.database.prepare(`SELECT * FROM Connections WHERE id = ?`).get(id) as DBConnectionsTableSchema | undefined
|
|
||||||
if (!result) continue
|
|
||||||
|
|
||||||
const { userId, type, service, tokens } = result
|
const { userId, type, service, tokens } = result
|
||||||
const parsedService = service ? JSON.parse(service) : undefined
|
const parsedService = service ? JSON.parse(service) : undefined
|
||||||
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
|
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
|
||||||
connectionInfo.push({ id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens })
|
return { id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens }
|
||||||
}
|
|
||||||
return connectionInfo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getUserConnectionInfo = (userId: string): ConnectionRow[] => {
|
public getUserConnectionInfo = (userId: string): ConnectionRow[] | undefined => {
|
||||||
|
const user = this.getUser(userId)
|
||||||
|
if (!user) return undefined
|
||||||
|
|
||||||
const connectionRows = this.database.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as DBConnectionsTableSchema[]
|
const connectionRows = this.database.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as DBConnectionsTableSchema[]
|
||||||
const connections: ConnectionRow[] = []
|
const connections: ConnectionRow[] = []
|
||||||
for (const { id, type, service, tokens } of connectionRows) {
|
for (const { id, type, service, tokens } of connectionRows) {
|
||||||
|
|||||||
120
src/lib/server/youtube-music-types.d.ts
vendored
120
src/lib/server/youtube-music-types.d.ts
vendored
@@ -210,6 +210,7 @@ export namespace InnerTube {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace Album {
|
||||||
interface AlbumResponse {
|
interface AlbumResponse {
|
||||||
contents: {
|
contents: {
|
||||||
singleColumnBrowseResultsRenderer: {
|
singleColumnBrowseResultsRenderer: {
|
||||||
@@ -221,38 +222,15 @@ export namespace InnerTube {
|
|||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
musicShelfRenderer: {
|
musicShelfRenderer: {
|
||||||
contents: Array<{
|
contents: Array<AlbumItem>
|
||||||
musicResponsiveListItemRenderer: {
|
continuations?: [
|
||||||
flexColumns: Array<{
|
|
||||||
musicResponsiveListItemFlexColumnRenderer: {
|
|
||||||
text: {
|
|
||||||
runs?: [
|
|
||||||
{
|
{
|
||||||
text: string
|
nextContinuationData: {
|
||||||
navigationEndpoint?: {
|
continuation: string
|
||||||
watchEndpoint: watchEndpoint
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}>
|
|
||||||
fixedColumns: [
|
|
||||||
{
|
|
||||||
musicResponsiveListItemFixedColumnRenderer: {
|
|
||||||
text: {
|
|
||||||
runs: [
|
|
||||||
{
|
|
||||||
text: string
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -296,6 +274,89 @@ export namespace InnerTube {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlbumItem = {
|
||||||
|
musicResponsiveListItemRenderer: {
|
||||||
|
flexColumns: [
|
||||||
|
{
|
||||||
|
musicResponsiveListItemFlexColumnRenderer: {
|
||||||
|
text: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
watchEndpoint: watchEndpoint
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
musicResponsiveListItemFlexColumnRenderer: {
|
||||||
|
text: {
|
||||||
|
runs?: {
|
||||||
|
text: string
|
||||||
|
navigationEndpoint?: {
|
||||||
|
browseEndpoint: browseEndpoint
|
||||||
|
}
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixedColumns: [
|
||||||
|
{
|
||||||
|
musicResponsiveListItemFixedColumnRenderer: {
|
||||||
|
text: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentShelf = {
|
||||||
|
contents: Array<AlbumItem>
|
||||||
|
continuations?: [
|
||||||
|
{
|
||||||
|
nextContinuationData: {
|
||||||
|
continuation: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContinuationResponse {
|
||||||
|
continuationContents: {
|
||||||
|
musicShelfContinuation: ContentShelf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Player {
|
||||||
|
interface PlayerResponse {
|
||||||
|
streamingData: {
|
||||||
|
formats: Format[]
|
||||||
|
adaptiveFormats: Format[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Format = {
|
||||||
|
itag: number
|
||||||
|
url?: string // Only present for Android client requests, not web requests
|
||||||
|
mimeType: string
|
||||||
|
bitrate: number
|
||||||
|
qualityLabel?: string // If present, format contains video (144p, 240p, 360p, etc.)
|
||||||
|
audioQuality?: string // If present, format contains audio (AUDIO_QUALITY_LOW, AUDIO_QUALITY_MEDIUM, AUDIO_QUALITY_HIGH)
|
||||||
|
signatureCipher?: string // Only present for Web client requests, not android requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface SearchResponse {
|
interface SearchResponse {
|
||||||
contents: {
|
contents: {
|
||||||
tabbedSearchResultsRenderer: {
|
tabbedSearchResultsRenderer: {
|
||||||
@@ -312,6 +373,9 @@ export namespace InnerTube {
|
|||||||
| {
|
| {
|
||||||
musicShelfRenderer: musicShelfRenderer
|
musicShelfRenderer: musicShelfRenderer
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
itemSectionRenderer: unknown
|
||||||
|
}
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,7 +461,7 @@ export namespace InnerTube {
|
|||||||
title: {
|
title: {
|
||||||
runs: [
|
runs: [
|
||||||
{
|
{
|
||||||
text: 'Listen again' | 'Forgotten favorites' | 'Quick picks' | 'New releases' | 'From your library'
|
text: string
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { google, type youtube_v3 } from 'googleapis'
|
import { google, type youtube_v3 } from 'googleapis'
|
||||||
import ytdl from 'ytdl-core'
|
|
||||||
import { DB } from './db'
|
import { DB } from './db'
|
||||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||||
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
||||||
@@ -56,22 +55,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
this.expiry = expiry
|
this.expiry = expiry
|
||||||
}
|
}
|
||||||
|
|
||||||
private get innertubeRequestHeaders() {
|
// TODO: Need to figure out a way to prevent this from this refresh the access token twice in the event that it is requested again while awaiting the first refreshed token
|
||||||
return (async () => {
|
|
||||||
return new Headers({
|
|
||||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
|
|
||||||
accept: '*/*',
|
|
||||||
'accept-encoding': 'gzip, deflate',
|
|
||||||
'content-type': 'application/json',
|
|
||||||
'content-encoding': 'gzip',
|
|
||||||
origin: 'https://music.youtube.com',
|
|
||||||
Cookie: 'SOCS=CAI;',
|
|
||||||
authorization: `Bearer ${await this.accessToken}`,
|
|
||||||
'X-Goog-Request-Time': `${Date.now()}`,
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
private get accessToken() {
|
private get accessToken() {
|
||||||
return (async () => {
|
return (async () => {
|
||||||
const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => {
|
const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => {
|
||||||
@@ -124,56 +108,83 @@ export class YouTubeMusic implements Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) {
|
private async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) {
|
||||||
|
const headers = new Headers({
|
||||||
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
|
||||||
|
accept: '*/*',
|
||||||
|
'accept-encoding': 'gzip, deflate',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-encoding': 'gzip',
|
||||||
|
origin: 'https://music.youtube.com',
|
||||||
|
authorization: `Bearer ${await this.accessToken}`,
|
||||||
|
})
|
||||||
|
|
||||||
const currentDate = new Date()
|
const currentDate = new Date()
|
||||||
const year = currentDate.getUTCFullYear().toString()
|
const year = currentDate.getUTCFullYear().toString()
|
||||||
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
|
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
|
||||||
const day = currentDate.getUTCDate().toString().padStart(2, '0')
|
const day = currentDate.getUTCDate().toString().padStart(2, '0')
|
||||||
|
|
||||||
let url = 'https://music.youtube.com/youtubei/v1/'
|
const context = {
|
||||||
|
|
||||||
const body: { [k: string]: any } = {
|
|
||||||
context: {
|
|
||||||
client: {
|
client: {
|
||||||
clientName: 'WEB_REMIX',
|
clientName: 'WEB_REMIX',
|
||||||
clientVersion: `1.${year + month + day}.01.00`,
|
clientVersion: `1.${year + month + day}.01.00`,
|
||||||
hl: 'en',
|
hl: 'en',
|
||||||
},
|
},
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchData = (): [URL, Object] => {
|
||||||
switch (requestDetails.type) {
|
switch (requestDetails.type) {
|
||||||
case 'browse':
|
case 'browse':
|
||||||
url = url.concat('browse')
|
return [
|
||||||
body['browseId'] = requestDetails.browseId
|
new URL('https://music.youtube.com/youtubei/v1/browse'),
|
||||||
break
|
{
|
||||||
|
browseId: requestDetails.browseId,
|
||||||
|
context,
|
||||||
|
},
|
||||||
|
]
|
||||||
case 'search':
|
case 'search':
|
||||||
url = url.concat('search')
|
return [
|
||||||
if (requestDetails.filter) body['params'] = searchFilterParams[requestDetails.filter]
|
new URL('https://music.youtube.com/youtubei/v1/search'),
|
||||||
body['query'] = requestDetails.searchTerm
|
{
|
||||||
break
|
query: requestDetails.searchTerm,
|
||||||
|
filter: requestDetails.filter ? searchFilterParams[requestDetails.filter] : undefined,
|
||||||
|
context,
|
||||||
|
},
|
||||||
|
]
|
||||||
case 'continuation':
|
case 'continuation':
|
||||||
url = url.concat(`browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`)
|
return [
|
||||||
break
|
new URL(`https://music.youtube.com/youtubei/v1/browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`),
|
||||||
|
{
|
||||||
|
context,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(url, {
|
const [url, body] = fetchData()
|
||||||
headers: await this.innertubeRequestHeaders,
|
|
||||||
method: 'POST',
|
return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) })
|
||||||
body: JSON.stringify(body),
|
|
||||||
}).then((response) => response.json())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Figure out why this still breaks sometimes
|
||||||
|
public async search(searchTerm: string, filter: 'song'): Promise<Song[]>
|
||||||
|
public async search(searchTerm: string, filter: 'album'): Promise<Album[]>
|
||||||
|
public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]>
|
||||||
|
public async search(searchTerm: string, filter: 'playlist'): Promise<Playlist[]>
|
||||||
|
public async search(searchTerm: string, filter?: undefined): Promise<(Song | Album | Artist | Playlist)[]>
|
||||||
public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> {
|
public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> {
|
||||||
// Figure out how to handle Library and Uploads
|
// Figure out how to handle Library and Uploads
|
||||||
// Depending on how I want to handle the playlist & library sync feature
|
// Depending on how I want to handle the playlist & library sync feature
|
||||||
|
|
||||||
const searchResulsts = (await this.ytMusicv1ApiRequest({ type: 'search', searchTerm, filter })) as InnerTube.SearchResponse
|
const searchResulsts = (await this.ytMusicv1ApiRequest({ type: 'search', searchTerm, filter }).then((response) => response.json())) as InnerTube.SearchResponse
|
||||||
|
|
||||||
const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||||
|
|
||||||
const parsedSearchResults: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = []
|
try {
|
||||||
|
const parsedSearchResults = []
|
||||||
const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists']
|
const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists']
|
||||||
for (const section of contents) {
|
for (const section of contents) {
|
||||||
|
if ('itemSectionRenderer' in section) continue
|
||||||
|
|
||||||
if ('musicCardShelfRenderer' in section) {
|
if ('musicCardShelfRenderer' in section) {
|
||||||
parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer))
|
parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer))
|
||||||
section.musicCardShelfRenderer.contents?.forEach((item) => {
|
section.musicCardShelfRenderer.contents?.forEach((item) => {
|
||||||
@@ -191,16 +202,23 @@ export class YouTubeMusic implements Connection {
|
|||||||
parsedSearchResults.push(...parsedSectionContents)
|
parsedSearchResults.push(...parsedSectionContents)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.scrapedToMediaItems(parsedSearchResults)
|
return this.scrapedToMediaItems(parsedSearchResults)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
console.log(JSON.stringify(contents))
|
||||||
|
throw Error('Something fucked up')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
|
// TODO: Figure out why this still breaks sometimes
|
||||||
const homeResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' })) as InnerTube.HomeResponse
|
public async getRecommendations() {
|
||||||
|
const homeResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse
|
||||||
|
|
||||||
const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||||
|
|
||||||
|
try {
|
||||||
const scrapedRecommendations: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = []
|
const scrapedRecommendations: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = []
|
||||||
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
|
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library', 'Recommended music videos', 'Recommended albums']
|
||||||
for (const section of contents) {
|
for (const section of contents) {
|
||||||
const sectionType = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text
|
const sectionType = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text
|
||||||
if (!goodSections.includes(sectionType)) continue
|
if (!goodSections.includes(sectionType)) continue
|
||||||
@@ -211,27 +229,86 @@ export class YouTubeMusic implements Connection {
|
|||||||
scrapedRecommendations.push(...parsedContent)
|
scrapedRecommendations.push(...parsedContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.scrapedToMediaItems(scrapedRecommendations)
|
return this.scrapedToMediaItems(scrapedRecommendations)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
console.log(JSON.stringify(contents))
|
||||||
|
throw Error('Something fucked up')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAudioStream(id: string, range: string | null): Promise<Response> {
|
public async getAudioStream(id: string, headers: Headers): Promise<Response> {
|
||||||
const videoInfo = await ytdl.getInfo(`http://www.youtube.com/watch?v=${id}`)
|
if (!/^[a-zA-Z0-9-_]{11}$/.test(id)) throw TypeError('Invalid youtube video ID')
|
||||||
const format = ytdl.chooseFormat(videoInfo.formats, { quality: 'highestaudio', filter: 'audioonly' })
|
|
||||||
|
|
||||||
const headers = new Headers({ range: range || '0-' })
|
// ? In the future, may want to implement the original web client method both in order to bypass age-restrictions and just to serve as a fallback
|
||||||
|
// ? However this has the downsides of being slower and requiring the user's cookies if the video is premium exclusive.
|
||||||
|
// ? Ideally, I want to avoid having to mess with a user's cookies at all costs because:
|
||||||
|
// ? a) It's another security risk
|
||||||
|
// ? b) A user would have to manually copy them over, which is about as user friendly as a kick to the face
|
||||||
|
// ? c) Cookies get updated with every request, meaning the db would get hit more frequently, and it's just another thing to maintain
|
||||||
|
// ? Ulimately though, I may have to implment cookie support anyway dependeding on how youtube tracks a user's watch history and prefrences
|
||||||
|
|
||||||
return await fetch(format.url, { headers })
|
// * MASSIVE props and credit to Oleksii Holub (https://github.com/Tyrrrz) for documenting the android client method of player fetching: https://tyrrrz.me/blog/reverse-engineering-youtube-revisited.
|
||||||
|
// * Go support him and go support Ukraine (he's Ukrainian)
|
||||||
|
|
||||||
|
// TODO: Differentiate errors thrown by the player fetch and handle them respectively, rather than just a global catch. (Throw TypeError if the request contained an invalid videoId)
|
||||||
|
const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', {
|
||||||
|
headers: {
|
||||||
|
'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip',
|
||||||
|
accept: '*/*',
|
||||||
|
'accept-encoding': 'gzip, deflate',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-encoding': 'gzip',
|
||||||
|
origin: 'https://music.youtube.com',
|
||||||
|
authorization: `Bearer ${await this.accessToken}`,
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
videoId: id,
|
||||||
|
context: {
|
||||||
|
client: {
|
||||||
|
clientName: 'ANDROID_TESTSUITE',
|
||||||
|
clientVersion: '1.9',
|
||||||
|
androidSdkVersion: 30,
|
||||||
|
hl: 'en',
|
||||||
|
gl: 'US',
|
||||||
|
utcOffsetMinutes: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((response) => response.json() as Promise<InnerTube.Player.PlayerResponse>)
|
||||||
|
.catch(() => null)
|
||||||
|
|
||||||
|
if (!playerResponse) throw Error(`Failed to fetch player for song ${id} of connection ${this.id}`)
|
||||||
|
|
||||||
|
const audioOnlyFormats = playerResponse.streamingData.formats.concat(playerResponse.streamingData.adaptiveFormats).filter(
|
||||||
|
(format): format is HasDefinedProperty<InnerTube.Player.Format, 'url' | 'audioQuality'> =>
|
||||||
|
format.qualityLabel === undefined &&
|
||||||
|
format.audioQuality !== undefined &&
|
||||||
|
format.url !== undefined &&
|
||||||
|
!/\bsource[/=]yt_live_broadcast\b/.test(format.url) && // Filters out live broadcasts
|
||||||
|
!/\/manifest\/hls_(variant|playlist)\//.test(format.url) && // Filters out HLS streams
|
||||||
|
!/\/manifest\/dash\//.test(format.url), // Filters out DashMPD streams
|
||||||
|
// ? For each of the three above filters, I may want to look into how to support them.
|
||||||
|
// ? Especially live streams, being able to support those live music stream channels seems like a necessary feature.
|
||||||
|
// ? HLS and DashMPD I *think* are more efficient so it would be nice to support those too.
|
||||||
|
)
|
||||||
|
|
||||||
|
if (audioOnlyFormats.length === 0) throw Error(`No valid audio formats returned for song ${id} of connection ${this.id}`)
|
||||||
|
|
||||||
|
const hqAudioFormat = audioOnlyFormats.reduce((previous, current) => (previous.bitrate > current.bitrate ? previous : current))
|
||||||
|
|
||||||
|
return fetch(hqAudioFormat.url, { headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id The browseId of the album
|
* @param id The browseId of the album
|
||||||
* @returns Basic info about the album in the Album type schema.
|
|
||||||
*/
|
*/
|
||||||
public async getAlbum(id: string): Promise<Album> {
|
public async getAlbum(id: string): Promise<Album> {
|
||||||
const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id })) as InnerTube.AlbumResponse
|
const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
|
||||||
|
|
||||||
const header = albumResponse.header.musicDetailHeaderRenderer
|
const header = albumResponse.header.musicDetailHeaderRenderer
|
||||||
const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents
|
|
||||||
|
|
||||||
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
|
||||||
const name = header.title.runs[0].text,
|
const name = header.title.runs[0].text,
|
||||||
@@ -249,23 +326,82 @@ export class YouTubeMusic implements Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const releaseDate = header.subtitle.runs.at(-1)?.text!,
|
const releaseYear = header.subtitle.runs.at(-1)?.text!
|
||||||
length = contents.length
|
|
||||||
|
|
||||||
const duration = contents.reduce(
|
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
|
||||||
(accumulator, current) => (accumulator += timestampToSeconds(current.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)),
|
}
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
return { connection, id, name, type: 'album', duration, thumbnailUrl, artists, releaseDate, length } satisfies Album
|
/**
|
||||||
|
* @param id The browseId of the album
|
||||||
|
*/
|
||||||
|
public async getAlbumItems(id: string): Promise<Song[]> {
|
||||||
|
const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
|
||||||
|
|
||||||
|
const header = albumResponse.header.musicDetailHeaderRenderer
|
||||||
|
|
||||||
|
const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents
|
||||||
|
let continuation = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
|
||||||
|
const thumbnailUrl = cleanThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||||
|
const album: Song['album'] = { id, name: header.title.runs[0].text }
|
||||||
|
|
||||||
|
const albumArtists = header.subtitle.runs
|
||||||
|
.filter((run) => run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ARTIST')
|
||||||
|
.map((run) => ({ id: run.navigationEndpoint!.browseEndpoint.browseId, name: run.text }))
|
||||||
|
|
||||||
|
while (continuation) {
|
||||||
|
const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Album.ContinuationResponse
|
||||||
|
|
||||||
|
contents.push(...continuationResponse.continuationContents.musicShelfContinuation.contents)
|
||||||
|
continuation = continuationResponse.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just putting this here in the event that for some reason an album has non-playlable items, never seen it happen but couldn't hurt
|
||||||
|
const playableItems = contents.filter((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined)
|
||||||
|
|
||||||
|
const dividedItems = []
|
||||||
|
for (let i = 0; i < playableItems.length; i += 50) dividedItems.push(playableItems.slice(i, i + 50))
|
||||||
|
|
||||||
|
const access_token = await this.accessToken
|
||||||
|
const videoSchemas = await Promise.all(
|
||||||
|
dividedItems.map((chunk) =>
|
||||||
|
ytDataApi.videos.list({
|
||||||
|
part: ['snippet'],
|
||||||
|
id: chunk.map((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId),
|
||||||
|
access_token,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).then((responses) => responses.map((response) => response.data.items!).flat())
|
||||||
|
|
||||||
|
const descriptionRelease = videoSchemas.find((video) => video.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] !== undefined)?.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0]
|
||||||
|
const releaseDate = new Date(descriptionRelease ?? header.subtitle.runs.at(-1)?.text!).toISOString()
|
||||||
|
|
||||||
|
const videoChannelMap = new Map<string, string>()
|
||||||
|
videoSchemas.forEach((video) => videoChannelMap.set(video.id!, video.snippet?.channelId!))
|
||||||
|
|
||||||
|
return playableItems.map((item) => {
|
||||||
|
const [col0, col1] = item.musicResponsiveListItemRenderer.flexColumns
|
||||||
|
|
||||||
|
const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
|
||||||
|
const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
|
||||||
|
|
||||||
|
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
|
||||||
|
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
||||||
|
|
||||||
|
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
|
||||||
|
|
||||||
|
const artists = col1.musicResponsiveListItemFlexColumnRenderer.text.runs?.map((run) => ({ id: run.navigationEndpoint?.browseEndpoint.browseId ?? videoChannelMap.get(id)!, name: run.text })) ?? albumArtists
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id The id of the playlist (not the browseId!).
|
* @param id The id of the playlist (not the browseId!).
|
||||||
* @returns Basic info about the playlist in the Playlist type schema.
|
|
||||||
*/
|
*/
|
||||||
public async getPlaylist(id: string): Promise<Playlist> {
|
public async getPlaylist(id: string): Promise<Playlist> {
|
||||||
const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })) as InnerTube.Playlist.PlaylistResponse
|
const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }).then((response) => response.json())) as InnerTube.Playlist.PlaylistResponse
|
||||||
|
|
||||||
const header =
|
const header =
|
||||||
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.header
|
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.header
|
||||||
@@ -276,7 +412,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
const name = header.title.runs[0].text
|
const name = header.title.runs[0].text
|
||||||
|
|
||||||
const thumbnailUrl = cleanThumbnailUrl(
|
const thumbnailUrl = cleanThumbnailUrl(
|
||||||
header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url,
|
header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url, // This is because sometimes the thumbnails for playlists can be video thumbnails
|
||||||
)
|
)
|
||||||
|
|
||||||
let createdBy: Playlist['createdBy']
|
let createdBy: Playlist['createdBy']
|
||||||
@@ -284,61 +420,84 @@ export class YouTubeMusic implements Connection {
|
|||||||
if (run.navigationEndpoint?.browseEndpoint.browseId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
if (run.navigationEndpoint?.browseEndpoint.browseId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
||||||
})
|
})
|
||||||
|
|
||||||
const trackCountText = header.secondSubtitle.runs.find((run) => run.text.includes('tracks'))!.text // "### tracks"
|
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
|
||||||
const length = Number(trackCountText.split(' ')[0])
|
|
||||||
|
|
||||||
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy, length } satisfies Playlist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id The id of the playlist (not the browseId!).
|
||||||
|
*/
|
||||||
|
// TODO: Add startIndex and length parameters
|
||||||
public async getPlaylistItems(id: string): Promise<Song[]> {
|
public async getPlaylistItems(id: string): Promise<Song[]> {
|
||||||
const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })) as InnerTube.Playlist.PlaylistResponse
|
const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }).then((response) => response.json())) as InnerTube.Playlist.PlaylistResponse
|
||||||
|
|
||||||
const contents = playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents
|
const contents = playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents
|
||||||
let continuation =
|
let continuation =
|
||||||
playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation
|
playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
while (continuation) {
|
while (continuation) {
|
||||||
const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation })) as InnerTube.Playlist.ContinuationResponse
|
const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Playlist.ContinuationResponse
|
||||||
|
|
||||||
contents.push(...continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents)
|
contents.push(...continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents)
|
||||||
continuation = continuationResponse.continuationContents.musicPlaylistShelfContinuation.continuations?.[0].nextContinuationData.continuation
|
continuation = continuationResponse.continuationContents.musicPlaylistShelfContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlistItems: InnerTube.ScrapedSong[] = []
|
|
||||||
contents.forEach((item) => {
|
|
||||||
const [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns
|
|
||||||
|
|
||||||
// This is simply to handle completely fucked playlists where the playlist items might be missing navigation endpoints (e.g. Deleted Videos)
|
// 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?)
|
// or in some really bad cases, have a navigationEndpoint, but not a watchEndpoint somehow (Possibly for unlisted/private content?)
|
||||||
if (!col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId) return
|
const playableItems = contents.filter((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined)
|
||||||
|
|
||||||
const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
|
const scrapedItems = playableItems.map((item) => {
|
||||||
|
const [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns
|
||||||
|
|
||||||
|
const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.videoId
|
||||||
const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
|
const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
|
||||||
|
|
||||||
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
|
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
|
||||||
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
||||||
|
|
||||||
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||||
|
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
|
||||||
|
|
||||||
const col2run = col2.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
|
const col2run = col2.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
|
||||||
const album: Song['album'] = col2run ? { id: col2run.navigationEndpoint.browseEndpoint.browseId, name: col2run.text } : undefined
|
const album: Song['album'] = col2run ? { id: col2run.navigationEndpoint.browseEndpoint.browseId, name: col2run.text } : undefined
|
||||||
|
|
||||||
let artists: Song['artists'] = [],
|
let artists: { id?: string; name: string }[] | undefined = [],
|
||||||
uploader: Song['uploader']
|
uploader: { id?: string; name: string } | undefined
|
||||||
|
|
||||||
for (const run of col1.musicResponsiveListItemFlexColumnRenderer.text.runs) {
|
for (const run of col1.musicResponsiveListItemFlexColumnRenderer.text.runs) {
|
||||||
if (!run.navigationEndpoint) continue
|
|
||||||
|
|
||||||
const pageType = run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
const pageType = run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||||
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
const runData = { id: run.navigationEndpoint?.browseEndpoint.browseId, name: run.text }
|
||||||
|
|
||||||
pageType === 'MUSIC_PAGE_TYPE_ARTIST' ? artists.push(runData) : (uploader = runData)
|
pageType === 'MUSIC_PAGE_TYPE_ARTIST' ? artists.push(runData) : (uploader = runData)
|
||||||
}
|
}
|
||||||
|
|
||||||
playlistItems.push({ id, name, type: 'song', thumbnailUrl, artists, album, uploader, isVideo })
|
if (artists.length === 0) artists = undefined
|
||||||
|
|
||||||
|
return { id, name, duration, thumbnailUrl, artists, album, uploader, isVideo }
|
||||||
})
|
})
|
||||||
|
|
||||||
return await this.scrapedToMediaItems(playlistItems)
|
const dividedItems = []
|
||||||
|
for (let i = 0; i < scrapedItems.length; i += 50) dividedItems.push(scrapedItems.slice(i, i + 50))
|
||||||
|
|
||||||
|
const access_token = await this.accessToken
|
||||||
|
const videoSchemaMap = new Map<string, youtube_v3.Schema$Video>()
|
||||||
|
const videoSchemas = (await Promise.all(dividedItems.map((chunk) => ytDataApi.videos.list({ part: ['snippet'], id: chunk.map((item) => item.id), access_token })))).map((response) => response.data.items!).flat()
|
||||||
|
videoSchemas.forEach((schema) => videoSchemaMap.set(schema.id!, schema))
|
||||||
|
|
||||||
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
|
||||||
|
return scrapedItems.map((item) => {
|
||||||
|
const correspondingSchema = videoSchemaMap.get(item.id)!
|
||||||
|
const { id, name, duration, album, isVideo } = item
|
||||||
|
const existingThumbnail = item.thumbnailUrl
|
||||||
|
const artists = item.artists?.map((artist) => ({ id: artist.id ?? correspondingSchema.snippet?.channelId!, name: artist.name }))
|
||||||
|
const uploader = item.uploader ? { id: item.uploader?.id ?? correspondingSchema.snippet?.channelId!, name: item.uploader.name } : undefined
|
||||||
|
|
||||||
|
const videoThumbnails = correspondingSchema.snippet?.thumbnails!
|
||||||
|
|
||||||
|
const thumbnailUrl = existingThumbnail ?? videoThumbnails.maxres?.url ?? videoThumbnails.standard?.url ?? videoThumbnails.high?.url ?? videoThumbnails.medium?.url ?? videoThumbnails.default?.url!
|
||||||
|
const releaseDate = new Date(correspondingSchema.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? correspondingSchema.snippet?.publishedAt!).toISOString()
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, uploader, isVideo } satisfies Song
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async scrapedToMediaItems<T extends (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[]>(scrapedItems: T): Promise<ScrapedMediaItemMap<T[number]>[]> {
|
private async scrapedToMediaItems<T extends (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[]>(scrapedItems: T): Promise<ScrapedMediaItemMap<T[number]>[]> {
|
||||||
@@ -367,9 +526,17 @@ export class YouTubeMusic implements Connection {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const songIdArray = Array.from(songIds)
|
||||||
|
const dividedIds: string[][] = []
|
||||||
|
for (let i = 0; i < songIdArray.length; i += 50) dividedIds.push(songIdArray.slice(i, i + 50))
|
||||||
|
|
||||||
const access_token = await this.accessToken
|
const access_token = await this.accessToken
|
||||||
|
|
||||||
const getSongDetails = () => ytDataApi.videos.list({ part: ['snippet', 'contentDetails'], id: Array.from(songIds), access_token })
|
const getSongDetails = () =>
|
||||||
|
Promise.all(dividedIds.map((idsChunk) => ytDataApi.videos.list({ part: ['snippet', 'contentDetails'], id: idsChunk, access_token }))).then((responses) =>
|
||||||
|
responses.map((response) => response.data.items!).flat(),
|
||||||
|
)
|
||||||
|
// Oh FFS. Despite nothing documenting it ^this api can only query a maximum of 50 ids at a time. Addtionally, if you exceed that limit, it doesn't even give you the correct error, it says some nonsense about an invalid filter paramerter. FML.
|
||||||
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
|
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
|
||||||
const getPlaylistDetails = () => Promise.all(Array.from(playlistIds).map((id) => this.getPlaylist(id)))
|
const getPlaylistDetails = () => Promise.all(Array.from(playlistIds).map((id) => this.getPlaylist(id)))
|
||||||
|
|
||||||
@@ -378,7 +545,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
albumDetailsMap = new Map<string, Album>(),
|
albumDetailsMap = new Map<string, Album>(),
|
||||||
playlistDetailsMap = new Map<string, Playlist>()
|
playlistDetailsMap = new Map<string, Playlist>()
|
||||||
|
|
||||||
songDetails.data.items!.forEach((item) => songDetailsMap.set(item.id!, item))
|
songDetails.forEach((item) => songDetailsMap.set(item.id!, item))
|
||||||
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
|
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
|
||||||
playlistDetails.forEach((playlist) => playlistDetailsMap.set(playlist.id, playlist))
|
playlistDetails.forEach((playlist) => playlistDetailsMap.set(playlist.id, playlist))
|
||||||
|
|
||||||
@@ -394,18 +561,11 @@ export class YouTubeMusic implements Connection {
|
|||||||
const thumbnails = songDetails.snippet?.thumbnails!
|
const thumbnails = songDetails.snippet?.thumbnails!
|
||||||
const thumbnailUrl = item.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
|
const thumbnailUrl = item.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
|
||||||
|
|
||||||
let songReleaseDate = new Date(songDetails.snippet?.publishedAt!)
|
const releaseDate = new Date(songDetails.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? songDetails.snippet?.publishedAt!).toISOString()
|
||||||
|
|
||||||
const albumDetails = album ? albumDetailsMap.get(album.id)! : undefined
|
const albumDetails = album ? albumDetailsMap.get(album.id)! : undefined
|
||||||
const fullAlbum = (albumDetails ? { id: albumDetails.id, name: albumDetails.name, thumbnailUrl: albumDetails.thumbnailUrl } : undefined) satisfies Song['album']
|
const fullAlbum = (albumDetails ? { id: albumDetails.id, name: albumDetails.name, thumbnailUrl: albumDetails.thumbnailUrl } : undefined) satisfies Song['album']
|
||||||
|
|
||||||
if (albumDetails?.releaseDate) {
|
|
||||||
const albumReleaseDate = new Date(albumDetails.releaseDate)
|
|
||||||
if (albumReleaseDate.getFullYear() < songReleaseDate.getFullYear()) songReleaseDate = albumReleaseDate
|
|
||||||
}
|
|
||||||
|
|
||||||
const releaseDate = songReleaseDate.toISOString()
|
|
||||||
|
|
||||||
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album: fullAlbum, isVideo, uploader } satisfies Song
|
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album: fullAlbum, isVideo, uploader } satisfies Song
|
||||||
case 'album':
|
case 'album':
|
||||||
return albumDetailsMap.get(item.id)! satisfies Album
|
return albumDetailsMap.get(item.id)! satisfies Album
|
||||||
@@ -444,8 +604,7 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
|
|||||||
|
|
||||||
if ('watchEndpoint' in rowContent.navigationEndpoint) {
|
if ('watchEndpoint' in rowContent.navigationEndpoint) {
|
||||||
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
|
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
|
||||||
const musicVideoType = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
|
const isVideo = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
||||||
const isVideo = musicVideoType === 'MUSIC_VIDEO_TYPE_UGC' || musicVideoType === 'MUSIC_VIDEO_TYPE_OMV'
|
|
||||||
const thumbnailUrl: InnerTube.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
const thumbnailUrl: InnerTube.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||||
|
|
||||||
let albumId: string | undefined
|
let albumId: string | undefined
|
||||||
@@ -476,6 +635,8 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
|
|||||||
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
|
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
|
||||||
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
||||||
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
||||||
|
default:
|
||||||
|
throw Error('Unexpected twoRowItem type: ' + pageType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,8 +667,9 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
|
|||||||
|
|
||||||
if (!('navigationEndpoint' in listContent)) {
|
if (!('navigationEndpoint' in listContent)) {
|
||||||
const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
|
const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
|
||||||
const musicVideoType = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
|
const isVideo =
|
||||||
const isVideo = musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !==
|
||||||
|
'MUSIC_VIDEO_TYPE_ATV'
|
||||||
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||||
|
|
||||||
const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
|
const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
|
||||||
@@ -532,6 +694,8 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
|
|||||||
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
|
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
|
||||||
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
||||||
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
||||||
|
default:
|
||||||
|
throw Error('Unexpected responsiveListItem type: ' + pageType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,8 +727,7 @@ function parseMusicCardShelfRenderer(cardContent: InnerTube.musicCardShelfRender
|
|||||||
const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint
|
const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint
|
||||||
if ('watchEndpoint' in navigationEndpoint) {
|
if ('watchEndpoint' in navigationEndpoint) {
|
||||||
const id = navigationEndpoint.watchEndpoint.videoId
|
const id = navigationEndpoint.watchEndpoint.videoId
|
||||||
const musicVideoType = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
|
const isVideo = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
||||||
const isVideo = musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
|
||||||
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
||||||
|
|
||||||
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.ScrapedSong
|
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.ScrapedSong
|
||||||
@@ -582,6 +745,8 @@ function parseMusicCardShelfRenderer(cardContent: InnerTube.musicCardShelfRender
|
|||||||
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
|
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
|
||||||
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
||||||
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
||||||
|
default:
|
||||||
|
throw Error('Unexpected musicCardShelf type: ' + pageType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,3 +804,42 @@ const timestampToSeconds = (timestamp: string) =>
|
|||||||
.split(':')
|
.split(':')
|
||||||
.reverse()
|
.reverse()
|
||||||
.reduce((accumulator, current, index) => (accumulator += Number(current) * 60 ** index), 0)
|
.reduce((accumulator, current, index) => (accumulator += Number(current) * 60 ** index), 0)
|
||||||
|
|
||||||
|
// * This method is designed to parse the cookies returned from a yt response in the Set-Cookie headers.
|
||||||
|
// * Keeping it here in case I ever need to implement management of a user's youtube cookies
|
||||||
|
function parseAndSetCookies(response: Response) {
|
||||||
|
const setCookieHeaders = response.headers.getSetCookie().map((header) => {
|
||||||
|
const keyValueStrings = header.split('; ')
|
||||||
|
const [name, value] = keyValueStrings[0].split('=')
|
||||||
|
const result: Record<string, string | number | boolean> = { name, value }
|
||||||
|
keyValueStrings.slice(1).forEach((string) => {
|
||||||
|
const [key, value] = string.split('=')
|
||||||
|
switch (key.toLowerCase()) {
|
||||||
|
case 'domain':
|
||||||
|
result.domain = value
|
||||||
|
break
|
||||||
|
case 'max-age':
|
||||||
|
result.expirationDate = Date.now() / 1000 + Number(value)
|
||||||
|
break
|
||||||
|
case 'expires':
|
||||||
|
result.expirationDate = result.expirationDate ? new Date(value).getTime() / 1000 : result.expirationDate // Max-Age takes precedence
|
||||||
|
break
|
||||||
|
case 'path':
|
||||||
|
result.path = value
|
||||||
|
break
|
||||||
|
case 'secure':
|
||||||
|
result.secure = true
|
||||||
|
break
|
||||||
|
case 'httponly':
|
||||||
|
result.httpOnly = true
|
||||||
|
break
|
||||||
|
case 'samesite':
|
||||||
|
const lowercaseValue = value.toLowerCase()
|
||||||
|
result.sameSite = lowercaseValue === 'none' ? 'no_restriction' : lowercaseValue
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(JSON.stringify(result))
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ class Queue {
|
|||||||
writableQueue.set(this)
|
writableQueue.set(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setQueue(...songs: Song[]) {
|
||||||
|
this.songs = songs
|
||||||
|
this.currentPosition = songs.length === 0 ? -1 : 0
|
||||||
|
writableQueue.set(this)
|
||||||
|
}
|
||||||
|
|
||||||
public clear() {
|
public clear() {
|
||||||
this.currentPosition = -1
|
this.currentPosition = -1
|
||||||
this.songs = []
|
this.songs = []
|
||||||
|
|||||||
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">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
import { queue } from '$lib/stores'
|
import { queue } from '$lib/stores'
|
||||||
import type { PageServerData } from './$types'
|
import type { PageServerData } from './$types'
|
||||||
|
|
||||||
@@ -24,7 +25,11 @@
|
|||||||
<button
|
<button
|
||||||
id="searchResult"
|
id="searchResult"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
if (searchResult.type === 'song') queueRef.current = searchResult
|
if (searchResult.type === 'song') {
|
||||||
|
queueRef.current = searchResult
|
||||||
|
} else {
|
||||||
|
goto(`/details/${searchResult.type}?id=${searchResult.id}&connection=${searchResult.connection.id}`)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
class="grid aspect-square h-full place-items-center bg-cover bg-center bg-no-repeat"
|
class="grid aspect-square h-full place-items-center bg-cover bg-center bg-no-repeat"
|
||||||
style="--thumbnail: url('/api/remoteImage?url={'thumbnailUrl' in searchResult ? searchResult.thumbnailUrl : searchResult.profilePicture}')"
|
style="--thumbnail: url('/api/remoteImage?url={'thumbnailUrl' in searchResult ? searchResult.thumbnailUrl : searchResult.profilePicture}')"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, serverUrl: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } })
|
const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, serverUrl: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } })
|
||||||
|
|
||||||
const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
|
const response = await fetch(`/api/connections?id=${newConnectionId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
@@ -57,7 +57,7 @@ export const actions: Actions = {
|
|||||||
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
|
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
|
const response = await fetch(`/api/connections?id=${newConnectionId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,26 +5,20 @@ export const GET: RequestHandler = async ({ url, request }) => {
|
|||||||
const connectionId = url.searchParams.get('connection')
|
const connectionId = url.searchParams.get('connection')
|
||||||
const id = url.searchParams.get('id')
|
const id = url.searchParams.get('id')
|
||||||
if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
|
if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
|
||||||
|
// Might want to re-evaluate how specific I make these ^ v error response messages
|
||||||
|
const connection = Connections.getConnection(connectionId)
|
||||||
|
if (!connection) return new Response('Invalid connection id', { status: 400 })
|
||||||
|
|
||||||
const range = request.headers.get('range')
|
const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' })
|
||||||
const connection = Connections.getConnections([connectionId])[0]
|
|
||||||
|
|
||||||
const fetchStream = async (): Promise<Response> => {
|
const response = await connection
|
||||||
const MAX_TRIES = 5
|
.getAudioStream(id, audioRequestHeaders)
|
||||||
let tries = 0
|
// * Withing the .getAudioStream() method of connections, a TypeError should be thrown if the request was invalid (e.g. non-existent id)
|
||||||
while (tries < MAX_TRIES) {
|
// * A standard Error should be thrown if the fetch to the service's server failed or the request returned invalid data
|
||||||
++tries
|
.catch((error: TypeError | Error) => {
|
||||||
const stream = await connection.getAudioStream(id, range).catch((reason) => {
|
if (error instanceof TypeError) return new Response('Malformed Request', { status: 400 })
|
||||||
console.error(`Audio stream fetch failed: ${reason}`)
|
return new Response('Failed to fetch valid audio stream', { status: 502 })
|
||||||
return null
|
|
||||||
})
|
})
|
||||||
if (!stream || !stream.ok) continue
|
|
||||||
|
|
||||||
return stream
|
return response
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Audio stream fetch to connection: ${connection.id} of id ${id} failed`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await fetchStream()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import type { RequestHandler } from '@sveltejs/kit'
|
|||||||
import { Connections } from '$lib/server/connections'
|
import { Connections } from '$lib/server/connections'
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',')
|
const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
|
||||||
if (!ids) return new Response('Missing ids query parameter', { status: 400 })
|
if (!ids) return new Response('Missing id query parameter', { status: 400 })
|
||||||
|
|
||||||
const connections: ConnectionInfo[] = []
|
const connections = (
|
||||||
for (const connection of Connections.getConnections(ids)) {
|
await Promise.all(
|
||||||
await connection
|
ids.map((id) =>
|
||||||
.getConnectionInfo()
|
Connections.getConnection(id)
|
||||||
.then((info) => connections.push(info))
|
?.getConnectionInfo()
|
||||||
.catch((reason) => console.log(`Failed to fetch connection info: ${reason}`))
|
.catch((reason) => {
|
||||||
}
|
console.error(`Failed to fetch connection info: ${reason}`)
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).filter((connection): connection is ConnectionInfo => connection?.id !== undefined)
|
||||||
|
|
||||||
return Response.json({ connections })
|
return Response.json({ connections })
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
let tries = 0
|
||||||
while (tries < MAX_TRIES) {
|
while (tries < MAX_TRIES) {
|
||||||
++tries
|
++tries
|
||||||
const response = await fetch(imageUrl).catch((reason) => {
|
const response = await fetch(imageUrl).catch(() => null)
|
||||||
console.error(`Image fetch to ${imageUrl} failed: ${reason}`)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
if (!response || !response.ok) continue
|
if (!response || !response.ok) continue
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type')
|
const contentType = response.headers.get('content-type')
|
||||||
if (!contentType || !contentType.startsWith('image')) throw new Error(`Url ${imageUrl} does not link to an image`)
|
if (!contentType || !contentType.startsWith('image')) throw Error(`Url ${imageUrl} does not link to an image`)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Exceed Max Retires')
|
throw Error(`Failed to fetch image at ${url} Exceed Max Retires`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await fetchImage()
|
return await fetchImage()
|
||||||
|
|||||||
@@ -2,18 +2,27 @@ import type { RequestHandler } from '@sveltejs/kit'
|
|||||||
import { Connections } from '$lib/server/connections'
|
import { Connections } from '$lib/server/connections'
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const query = url.searchParams.get('query')
|
const { query, userId, filter } = Object.fromEntries(url.searchParams) as { [k: string]: string | undefined }
|
||||||
if (!query) return new Response('Missing query parameter', { status: 400 })
|
if (!(query && userId)) return new Response('Missing search parameter', { status: 400 })
|
||||||
const userId = url.searchParams.get('userId')
|
|
||||||
if (!userId) return new Response('Missing userId parameter', { status: 400 })
|
|
||||||
|
|
||||||
const searchResults: (Song | Album | Artist | Playlist)[] = []
|
const userConnections = Connections.getUserConnections(userId)
|
||||||
for (const connection of Connections.getUserConnections(userId)) {
|
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||||
await connection
|
|
||||||
.search(query)
|
let checkedFilter: 'song' | 'album' | 'artist' | 'playlist' | undefined
|
||||||
.then((results) => searchResults.push(...results))
|
if (filter === 'song' || filter === 'album' || filter === 'artist' || filter === 'playlist') checkedFilter = filter
|
||||||
.catch((reason) => console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`))
|
|
||||||
}
|
const searchResults = (
|
||||||
|
await Promise.all(
|
||||||
|
userConnections.map((connection) =>
|
||||||
|
connection.search(query, checkedFilter).catch((reason) => {
|
||||||
|
console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
.filter((result): result is Song | Album | Artist | Playlist => result?.id !== undefined)
|
||||||
|
|
||||||
return Response.json({ searchResults })
|
return Response.json({ searchResults })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,19 @@ import { Connections } from '$lib/server/connections'
|
|||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const userId = params.userId!
|
const userId = params.userId!
|
||||||
|
|
||||||
const connections: ConnectionInfo[] = []
|
const userConnections = Connections.getUserConnections(userId)
|
||||||
for (const connection of Connections.getUserConnections(userId)) {
|
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||||
await connection
|
|
||||||
.getConnectionInfo()
|
const connections = (
|
||||||
.then((info) => connections.push(info))
|
await Promise.all(
|
||||||
.catch((reason) => console.log(`Failed to fetch connection info: ${reason}`))
|
userConnections.map((connection) =>
|
||||||
}
|
connection.getConnectionInfo().catch((reason) => {
|
||||||
|
console.log(`Failed to fetch connection info: ${reason}`)
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).filter((info): info is ConnectionInfo => info !== undefined)
|
||||||
|
|
||||||
return Response.json({ connections })
|
return Response.json({ connections })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,21 @@ import { Connections } from '$lib/server/connections'
|
|||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const userId = params.userId!
|
const userId = params.userId!
|
||||||
|
|
||||||
const recommendations: (Song | Album | Artist | Playlist)[] = []
|
const userConnections = Connections.getUserConnections(userId)
|
||||||
for (const connection of Connections.getUserConnections(userId)) {
|
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||||
await connection
|
|
||||||
.getRecommendations()
|
const recommendations = (
|
||||||
.then((connectionRecommendations) => recommendations.push(...connectionRecommendations))
|
await Promise.all(
|
||||||
.catch((reason) => console.log(`Failed to fetch recommendations: ${reason}`))
|
userConnections.map((connection) =>
|
||||||
}
|
connection.getRecommendations().catch((reason) => {
|
||||||
|
console.log(`Failed to fetch recommendations: ${reason}`)
|
||||||
|
return undefined
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
.filter((recommendation): recommendation is Song | Album | Artist | Playlist => recommendation?.id !== undefined)
|
||||||
|
|
||||||
return Response.json({ recommendations })
|
return Response.json({ recommendations })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user