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