Moved to ky for requests, significant improvements to YT client implementation with ky instances

This commit is contained in:
Eclypsed
2024-07-04 02:54:24 -04:00
parent f17773838a
commit 8453e51d3f
33 changed files with 2245 additions and 1370 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

1037
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,9 +33,10 @@
"bcrypt-ts": "^5.0.1", "bcrypt-ts": "^5.0.1",
"better-sqlite3": "^9.3.0", "better-sqlite3": "^9.3.0",
"fast-average-color": "^9.4.0", "fast-average-color": "^9.4.0",
"googleapis": "^133.0.0", "googleapis": "^140.0.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"knex": "^3.1.0", "knex": "^3.1.0",
"ky": "^1.4.0",
"zod": "^3.23.8" "zod": "^3.23.8"
} }
} }

13
src/app.d.ts vendored
View File

@@ -54,14 +54,6 @@ declare global {
playlist: Playlist playlist: Playlist
} }
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 readonly id: string public readonly id: string
@@ -73,15 +65,16 @@ declare global {
/** /**
* @param {string} searchTerm The string of text to query * @param {string} searchTerm The string of text to query
* @param {'song' | 'album' | 'artist' | 'playlist'} filter Optional. A string of either 'song', 'album', 'artist', or 'playlist' to filter the kind of media items queried * @param {Set<'song' | 'album' | 'artist' | 'playlist'>} types A set containing any of 'song', 'album', 'artist', or 'playlist'. Specifies what media types to query
* @returns {Promise<(Song | Album | Artist | Playlist)[]>} A promise of an array of media items * @returns {Promise<(Song | Album | Artist | Playlist)[]>} A promise of an array of media items
*/ */
search<T extends 'song' | 'album' | 'artist' | 'playlist'>(searchTerm: string, filter?: T): Promise<SearchFilterMap<T>[]> search<T extends keyof MediaItemTypeMap>(searchTerm: string, types: Set<T>): Promise<MediaItemTypeMap[T][]>
/** /**
* @param {string} id The id of the requested song * @param {string} id The id of the requested song
* @param {Headers} 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'). * @param {Headers} 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 {Promise<Response>} A promise of response object containing the audio stream for the specified byte range * @returns {Promise<Response>} A promise of response object containing the audio stream for the specified byte range
* @throws {TypeError | Error} TypeError if the id passed was invalid. Error if the connection failed to fetch the audio stream
* *
* Fetches the audio stream for a song. Will return an response containing the audio stream if the fetch was successfull, otherwise throw an error. * Fetches the audio stream for a song. Will return an response containing the audio stream if the fetch was successfull, otherwise throw an error.
*/ */

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { fade, slide } from 'svelte/transition' import { fade, slide, fly } from 'svelte/transition'
import { queue } from '$lib/stores' import { queue } from '$lib/stores'
import Services from '$lib/services.json'
// 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' import Loader from '$lib/components/util/loader.svelte'
@@ -9,6 +10,7 @@
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import ScrollingText from '$lib/components/util/scrollingText.svelte' import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from './artistList.svelte' import ArtistList from './artistList.svelte'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
// NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer. // NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer.
// That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own // That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own
@@ -34,8 +36,7 @@
seconds = seconds - hours * 3600 seconds = seconds - hours * 3600
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60)
seconds = seconds - minutes * 60 seconds = seconds - minutes * 60
const durationString = `${minutes}:${seconds.toString().padStart(2, '0')}` return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
return hours > 0 ? `${hours}:`.concat(durationString) : durationString
} }
$: updateMediaSession(currentlyPlaying) $: updateMediaSession(currentlyPlaying)
@@ -103,10 +104,15 @@
</script> </script>
{#if currentlyPlaying} {#if currentlyPlaying}
<div id="player-wrapper" transition:slide class="{expanded ? 'h-full w-full' : 'm-4 h-20 w-[calc(100%_-_32px)] rounded-xl'} absolute bottom-0 z-40 overflow-clip bg-neutral-925 transition-all duration-500"> <div
id="player-wrapper"
transition:slide
class="{expanded ? 'h-full w-full' : 'm-3 h-20 w-[calc(100%_-_24px)] rounded-xl'} absolute bottom-0 z-40 overflow-clip bg-neutral-925 transition-all ease-in-out"
style="transition-duration: 400ms;"
>
{#if !expanded} {#if !expanded}
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10 pr-8"> <main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10">
<section class="flex w-80 gap-3"> <section class="flex w-96 min-w-64 gap-3">
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl"> <div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} /> <LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} />
</div> </div>
@@ -140,7 +146,7 @@
<IconButton on:click={() => $queue.next()}> <IconButton on:click={() => $queue.next()}>
<i slot="icon" class="fa-solid fa-forward-step text-xl" /> <i slot="icon" class="fa-solid fa-forward-step text-xl" />
</IconButton> </IconButton>
<div class="flex flex-grow items-center justify-items-center gap-3 font-light"> <div class="flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
<span bind:this={currentTimeTimestamp} class="w-16 text-right" /> <span bind:this={currentTimeTimestamp} class="w-16 text-right" />
<Slider <Slider
bind:this={progressBar} bind:this={progressBar}
@@ -157,8 +163,8 @@
<span bind:this={durationTimestamp} class="w-16 text-left" /> <span bind:this={durationTimestamp} class="w-16 text-left" />
</div> </div>
</section> </section>
<section class="flex items-center justify-end gap-2.5 py-6 text-lg"> <section class="flex items-center justify-end gap-2.5 py-6 pr-8 text-lg">
<div id="volume-slider" class="mx-4 flex h-10 w-44 items-center gap-3"> <div class="mx-4 flex h-10 w-40 items-center gap-3">
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}> <IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" /> <i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</IconButton> </IconButton>
@@ -183,40 +189,41 @@
</main> </main>
{:else} {:else}
<main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full"> <main id="expanded-player" in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="relative h-full">
<div class="absolute -z-10 h-full w-full blur-xl brightness-[25%]"> <div class="absolute -z-10 h-full w-full blur-2xl brightness-[25%]">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={''} objectFit={'cover'} /> <LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={''} objectFit={'cover'} />
</div> </div>
<section id="song-queue-wrapper" class="h-full px-24 py-20"> <section class="relative grid h-full grid-rows-[1fr_4fr] gap-4 px-24 py-16">
<section class="relative"> <div class="grid grid-cols-[2fr_1fr]">
<div class="flex h-14 flex-row items-center gap-5">
<ServiceLogo type={currentlyPlaying.connection.type} />
<div>
<h1 class="text-neutral-400">STREAMING FROM</h1>
<strong class="text-2xl text-neutral-300">{Services[currentlyPlaying.connection.type].displayName}</strong>
</div>
</div>
<section>
{#if $queue.upNext}
{@const next = $queue.upNext}
<strong transition:fade class="ml-2 text-2xl">UP NEXT</strong>
<div transition:fly={{ x: 300 }} class="mt-3 flex h-20 w-full items-center gap-3 rounded-lg border border-neutral-300 bg-neutral-900 pr-3">
<div class="aspect-square h-full">
<LazyImage thumbnailUrl={next.thumbnailUrl} alt={`${next.name} jacket`} objectFit={'cover'} />
</div>
<div>
<div class="mb-0.5 line-clamp-1 font-medium">{next.name}</div>
<div class="line-clamp-1 text-sm font-light text-neutral-300">
<ArtistList mediaItem={next} linked={false} />
</div>
</div>
</div>
{/if}
</section>
</div>
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} /> <LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} />
</section> </section>
<section class="no-scrollbar flex max-h-full flex-col gap-3 overflow-y-scroll"> <section class="self-center px-16">
<strong class="ml-2 text-2xl">UP NEXT</strong> <div class="mb-7 flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
{#each $queue.list as item} <span bind:this={expandedCurrentTimeTimestamp} />
{@const isCurrent = item === currentlyPlaying}
<button
on:click={() => {
if (!isCurrent) $queue.setCurrent(item)
}}
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="h-20 w-20">
<LazyImage thumbnailUrl={item.thumbnailUrl} alt={`${item.name} jacket`} objectFit={'cover'} />
</div>
<div class="justify-items-left text-left">
<div class="line-clamp-1">{item.name}</div>
<div class="mt-[.15rem] line-clamp-1 text-neutral-400">{item.artists?.map((artist) => artist.name).join(', ') || item.uploader?.name}</div>
</div>
<span class="mr-4 text-right">{formatTime(item.duration)}</span>
</button>
{/each}
</section>
</section>
<section class="px-8">
<div id="progress-bar-expanded" class="mb-6">
<span bind:this={expandedCurrentTimeTimestamp} class="text-right" />
<Slider <Slider
bind:this={expandedProgressBar} bind:this={expandedProgressBar}
max={duration} max={duration}
@@ -229,59 +236,60 @@
seeking = false seeking = false
}} }}
/> />
<span bind:this={expandedDurationTimestamp} class="text-left" /> <span bind:this={expandedDurationTimestamp} />
</div> </div>
<div id="expanded-controls"> <div id="expanded-controls">
<div class="flex flex-col gap-1.5 overflow-hidden"> <div class="flex min-w-56 flex-col gap-1.5 overflow-hidden">
<div class="h-9"> <div class="h-10">
<ScrollingText> <ScrollingText>
<strong slot="text" class="text-3xl">{currentlyPlaying.name}</strong> <strong slot="text" class="text-4xl">{currentlyPlaying.name}</strong>
</ScrollingText> </ScrollingText>
</div> </div>
<div class="flex gap-3 text-lg font-medium text-neutral-300">
{#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader} {#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader}
<div class="line-clamp-1 flex flex-nowrap items-center font-extralight">
<i class="fa-solid fa-user mr-3 text-sm" />
<ArtistList mediaItem={currentlyPlaying} /> <ArtistList mediaItem={currentlyPlaying} />
</div>
{/if} {/if}
{#if currentlyPlaying.album} {#if currentlyPlaying.album}
<div class="flex flex-nowrap items-center font-extralight"> <strong>&bullet;</strong>
<i class="fa-solid fa-compact-disc mr-3 text-sm" />
<a <a
on:click={() => (expanded = false)} on:click={() => (expanded = false)}
class="line-clamp-1 flex-shrink-0 hover:underline focus:underline" class="line-clamp-1 hover:underline focus:underline"
href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a href="/details/album?id={currentlyPlaying.album.id}&connection={currentlyPlaying.connection.id}">{currentlyPlaying.album.name}</a
> >
</div>
{/if} {/if}
</div> </div>
<div class="flex h-min w-full items-center justify-center gap-2 text-2xl"> </div>
<button on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())} class="aspect-square h-16"> <div class="flex h-16 w-full items-center justify-center gap-2 text-2xl">
<i class="fa-solid {shuffled ? 'fa-shuffle' : 'fa-right-left'}" /> <IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
</button> <i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
<button class="aspect-square h-16" on:click={() => $queue.previous()}> </IconButton>
<i class="fa-solid fa-backward-step" /> <IconButton on:click={() => $queue.previous()}>
</button> <i slot="icon" class="fa-solid fa-backward-step text-xl" />
<button on:click={() => (paused = !paused)} class="relative grid aspect-square h-16 place-items-center rounded-full bg-white text-black"> </IconButton>
<div class="relative aspect-square h-full rounded-full bg-white text-black">
{#if waiting} {#if waiting}
<Loader size={2.5} /> <Loader size={1.5} />
{:else} {:else}
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" /> <IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if} {/if}
</button> </div>
<button class="aspect-square h-16" on:click={() => $queue.next()}> <IconButton on:click={() => $queue.clear()}>
<i class="fa-solid fa-forward-step" /> <i slot="icon" class="fa-solid fa-stop" />
</button> </IconButton>
<button on:click={() => (loop = !loop)} class="aspect-square h-16"> <IconButton on:click={() => $queue.next()}>
<i class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" /> <i slot="icon" class="fa-solid fa-forward-step" />
</button> </IconButton>
<IconButton on:click={() => (loop = !loop)}>
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
</div> </div>
<section class="flex h-min items-center justify-end gap-2 text-xl"> <section class="flex h-min items-center justify-end gap-2 text-xl">
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2"> <div class="mx-4 flex h-10 w-40 items-center gap-3">
<button on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))} class="aspect-square h-8"> <IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" /> <i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</button> </IconButton>
<div id="slider-wrapper" class="w-24 transition-all duration-500">
<Slider <Slider
bind:value={volume} bind:value={volume}
max={maxVolume} max={maxVolume}
@@ -290,13 +298,9 @@
}} }}
/> />
</div> </div>
</div> <IconButton on:click={() => (expanded = false)}>
<button class="aspect-square h-8" on:click={() => (expanded = false)}> <i slot="icon" class="fa-solid fa-chevron-down" />
<i class="fa-solid fa-compress" /> </IconButton>
</button>
<button class="aspect-square h-8" on:click={() => $queue.clear()}>
<i class="fa-solid fa-xmark" />
</button>
</section> </section>
</div> </div>
</section> </section>
@@ -314,7 +318,7 @@
on:waiting={() => (waiting = true)} on:waiting={() => (waiting = true)}
on:ended={() => $queue.next()} on:ended={() => $queue.next()}
on:error={() => setTimeout(() => audioElement.load(), 5000)} on:error={() => setTimeout(() => audioElement.load(), 5000)}
src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}" src="/api/v1/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
{loop} {loop}
/> />
</div> </div>
@@ -326,26 +330,12 @@
} }
#expanded-player { #expanded-player {
display: grid; display: grid;
grid-template-rows: calc(100% - 11rem) 11rem; grid-template-rows: 4fr 1fr;
}
#song-queue-wrapper {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 4rem;
}
.queue-item {
display: grid;
grid-template-columns: 5rem auto min-content;
}
#progress-bar-expanded {
display: grid;
grid-template-columns: min-content auto min-content;
align-items: center;
gap: 1rem;
} }
#expanded-controls { #expanded-controls {
display: grid; display: grid;
gap: 1rem; gap: 3rem;
align-items: center;
grid-template-columns: 1fr min-content 1fr !important; grid-template-columns: 1fr min-content 1fr !important;
} }
</style> </style>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
export let type: ConnectionType
</script>
{#if type === 'jellyfin'}
<!-- ***** BEGIN LICENSE BLOCK *****
- Part of the Jellyfin project (https://jellyfin.media)
-
- All copyright belongs to the Jellyfin contributors; a full list can
- be found in the file CONTRIBUTORS.md
-
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
- ***** END LICENSE BLOCK ***** -->
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
<defs>
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
<stop offset="0" style="stop-color:#AA5CC3" />
<stop offset="1" style="stop-color:#00A4DC" />
</linearGradient>
</defs>
<title>icon-transparent</title>
<g id="icon-transparent">
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)" />
<path
id="outer-shape"
d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z"
fill="url(#linear-gradient)"
/>
</g>
</svg>
{:else if type === 'youtube-music'}
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 176 176" enable-background="new 0 0 176 176" xml:space="preserve">
<metadata>
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="176" width="176" x="8" y="-184"></sliceSourceBounds>
</sfw>
</metadata>
<g id="XMLID_167_">
<circle id="XMLID_791_" fill="#FF0000" cx="88" cy="88" r="88" />
<path
id="XMLID_42_"
fill="#FFFFFF"
d="M88,46c23.1,0,42,18.8,42,42s-18.8,42-42,42s-42-18.8-42-42S64.9,46,88,46 M88,42
c-25.4,0-46,20.6-46,46s20.6,46,46,46s46-20.6,46-46S113.4,42,88,42L88,42z"
/>
<polygon id="XMLID_274_" fill="#FFFFFF" points="72,111 111,87 72,65" />
</g>
</svg>
{/if}
<style>
svg {
max-width: 100%;
max-height: 100%;
}
</style>

View File

@@ -58,16 +58,15 @@
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0;
} }
#slider-track:hover > #slider-trail { #slider-track:hover > #slider-trail,
background-color: var(--slider-color);
}
#slider-track:focus > #slider-trail { #slider-track:focus > #slider-trail {
background-color: var(--slider-color); background-color: var(--slider-color);
} }
#slider-track:hover > #slider-thumb { #slider-track:hover > #slider-thumb,
opacity: 1;
}
#slider-track:focus > #slider-thumb { #slider-track:focus > #slider-thumb {
opacity: 1; opacity: 1;
} }
#slider-track:not(:hover):not(:focus) > #slider-trail {
transition: right 50ms linear;
}
</style> </style>

View File

@@ -10,7 +10,37 @@ export async function mixExists(mixId: string): Promise<Boolean> {
return Boolean(await DB.mixes.where('id', mixId).first(DB.db.raw('EXISTS(SELECT 1)'))) return Boolean(await DB.mixes.where('id', mixId).first(DB.db.raw('EXISTS(SELECT 1)')))
} }
function connectionBuilder(schema: Schemas.Connections): Connection { export class ConnectionFactory {
/**
* Queries the database for a specific connection.
*
* @param {string} id The id of the connection
* @returns {Promise<Connection>} An instance of a Connection
* @throws {ReferenceError} ReferenceError if there is no connection with an id matches the one passed
*/
public static async getConnection(id: string): Promise<Connection> {
const schema = await DB.connections.where('id', id).first()
if (!schema) throw ReferenceError(`Connection of Id ${id} does not exist`)
return this.createConnection(schema)
}
/**
* Queries the database for all connections belong to a user of the specified id.
*
* @param {string} userId The id of a user
* @returns {Promise<Connection[]>} An array of connection instances for each of the user's connections
* @throws {ReferenceError} ReferenceError if there is no user with an id matches the one passed
*/
public static async getUserConnections(userId: string): Promise<Connection[]> {
const validUserId = await userExists(userId)
if (!validUserId) throw ReferenceError(`User of Id ${userId} does not exist`)
const connectionSchemas = await DB.connections.where('userId', userId).select('*')
return connectionSchemas.map(this.createConnection)
}
private static createConnection(schema: Schemas.Connections): Connection {
const { id, userId, type, serviceUserId, accessToken } = schema const { id, userId, type, serviceUserId, accessToken } = schema
switch (type) { switch (type) {
case 'jellyfin': case 'jellyfin':
@@ -19,30 +49,4 @@ function connectionBuilder(schema: Schemas.Connections): Connection {
return new YouTubeMusic(id, userId, serviceUserId, accessToken, schema.refreshToken, schema.expiry) return new YouTubeMusic(id, userId, serviceUserId, accessToken, schema.refreshToken, schema.expiry)
} }
} }
/**
* Queries the database for a specific connection.
*
* @param id The id of the connection
* @returns An instance of a Connection
* @throws ReferenceError if there is no connection with an id matches the one passed
*/
export async function buildConnection(id: string): Promise<Connection> {
const schema = await DB.connections.where('id', id).first()
if (!schema) throw ReferenceError(`Connection of Id ${id} does not exist`)
return connectionBuilder(schema)
}
/**
* Queries the database for all connections belong to a user of the specified id.
*
* @param userId The id of a user
* @returns An array of connection instances for each of the user's connections
* @throws ReferenceError if there is no user with an id matches the one passed
*/
export async function buildUserConnections(userId: string): Promise<Connection[]> {
if (!(await userExists(userId))) throw ReferenceError(`User of Id ${userId} does not exist`)
return (await DB.connections.where('userId', userId).select('*')).map(connectionBuilder)
} }

78
src/lib/server/jellyfin-types.d.ts vendored Normal file
View File

@@ -0,0 +1,78 @@
export namespace JellyfinAPI {
type Song = {
Name: string
Id: string
Type: 'Audio'
RunTimeTicks: number
PremiereDate?: string
ProductionYear?: number
ArtistItems?: {
Name: string
Id: string
}[]
Album?: string
AlbumId?: string
AlbumPrimaryImageTag?: string
AlbumArtists?: {
Name: string
Id: string
}[]
ImageTags?: {
Primary?: string
}
}
type Album = {
Name: string
Id: string
Type: 'MusicAlbum'
RunTimeTicks: number
PremiereDate?: string
ProductionYear?: number
ArtistItems?: {
Name: string
Id: string
}[]
AlbumArtists?: {
Name: string
Id: string
}[]
ImageTags?: {
Primary?: string
}
}
type Artist = {
Name: string
Id: string
Type: 'MusicArtist'
ImageTags?: {
Primary?: string
}
}
type Playlist = {
Name: string
Id: string
Type: 'Playlist'
RunTimeTicks: number
ChildCount: number
ImageTags?: {
Primary?: string
}
}
interface UserResponse {
Name: string
Id: string
}
interface AuthenticationResponse {
User: JellyfinAPI.UserResponse
AccessToken: string
}
interface SystemResponse {
ServerName: string
}
}

View File

@@ -1,4 +1,6 @@
import { PUBLIC_VERSION } from '$env/static/public' import { PUBLIC_VERSION } from '$env/static/public'
import type { JellyfinAPI } from './jellyfin-types'
import ky, { HTTPError, type KyInstance } from 'ky'
const jellyfinLogo = 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg' const jellyfinLogo = 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg'
@@ -6,88 +8,89 @@ export class Jellyfin implements Connection {
public readonly id: string public readonly id: string
private readonly userId: string private readonly userId: string
private readonly jellyfinUserId: string private readonly jellyfinUserId: string
private readonly serverUrl: string
private readonly services: JellyfinServices private readonly parsers: JellyfinParsers
private libraryManager?: JellyfinLibraryManager private libraryManager?: JellyfinLibraryManager
private readonly api: KyInstance
constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) { constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
this.id = id this.id = id
this.userId = userId this.userId = userId
this.jellyfinUserId = jellyfinUserId this.jellyfinUserId = jellyfinUserId
this.serverUrl = serverUrl
this.services = new JellyfinServices(this.id, serverUrl, accessToken) this.parsers = new JellyfinParsers(this.id, serverUrl)
const errorHook = (error: HTTPError) => {
console.error(`Request to ${new URL(error.request.url).pathname} failed: ${error.message} ${error.response.status}`)
return error
}
this.api = ky.create({
prefixUrl: serverUrl,
headers: { Authorization: `MediaBrowser Token="${accessToken}"` },
hooks: { beforeError: [errorHook] },
})
} }
public get library() { public get library() {
if (!this.libraryManager) this.libraryManager = new JellyfinLibraryManager(this.jellyfinUserId, this.services) if (!this.libraryManager) this.libraryManager = new JellyfinLibraryManager(this.jellyfinUserId, this.api, this.parsers)
return this.libraryManager return this.libraryManager
} }
// * This method can NOT throw an error
public async getConnectionInfo() { public async getConnectionInfo() {
const userEndpoint = `/Users/${this.jellyfinUserId}`
const systemEndpoint = '/System/Info'
const getUserData = () => const getUserData = () =>
this.services this.api(`Users/${this.jellyfinUserId}`)
.request(userEndpoint) .json<JellyfinAPI.UserResponse>()
.then((response) => response.json() as Promise<JellyfinAPI.UserResponse>)
.catch(() => null) .catch(() => null)
const getSystemData = () => const getSystemData = () =>
this.services this.api('System/Info')
.request(systemEndpoint) .json<JellyfinAPI.SystemResponse>()
.then((response) => response.json() as Promise<JellyfinAPI.SystemResponse>)
.catch(() => null) .catch(() => null)
const [userData, systemData] = await Promise.all([getUserData(), getSystemData()]) const [userData, systemData] = await Promise.all([getUserData(), getSystemData()])
if (!userData) console.error(`Fetch to ${userEndpoint} failed`)
if (!systemData) console.error(`Fetch to ${systemEndpoint} failed`)
return { return {
id: this.id, id: this.id,
userId: this.userId, userId: this.userId,
type: 'jellyfin', type: 'jellyfin',
serverUrl: this.services.serverUrl().toString(), serverUrl: this.serverUrl,
serverName: systemData?.ServerName, serverName: systemData?.ServerName,
jellyfinUserId: this.jellyfinUserId, jellyfinUserId: this.jellyfinUserId,
username: userData?.Name, username: userData?.Name,
} satisfies ConnectionInfo } satisfies ConnectionInfo
} }
public async search(searchTerm: string, filter: 'song'): Promise<Song[]> public async search<T extends keyof MediaItemTypeMap>(searchTerm: string, types: Set<T>): Promise<MediaItemTypeMap[T][]> {
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)[]> {
const filterMap = { song: 'Audio', album: 'MusicAlbum', artist: 'MusicArtist', playlist: 'Playlist' } as const const filterMap = { song: 'Audio', album: 'MusicAlbum', artist: 'MusicArtist', playlist: 'Playlist' } as const
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
searchTerm, searchTerm,
includeItemTypes: filter ? filterMap[filter] : Object.values(filterMap).join(','), includeItemTypes: Array.from(types, (type) => filterMap[type]).join(','),
recursive: 'true', recursive: 'true',
}) })
const searchResults = await this.services const searchResults = await this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`).json<{ Items: (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[] }>()
.request(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.then((response) => response.json() as Promise<{ Items: (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[] }>)
return searchResults.Items.map((result) => { return searchResults.Items.map((result) => {
switch (result.Type) { switch (result.Type) {
case 'Audio': case 'Audio':
return this.services.parseSong(result) return this.parsers.parseSong(result)
case 'MusicAlbum': case 'MusicAlbum':
return this.services.parseAlbum(result) return this.parsers.parseAlbum(result)
case 'MusicArtist': case 'MusicArtist':
return this.services.parseArtist(result) return this.parsers.parseArtist(result)
case 'Playlist': case 'Playlist':
return this.services.parsePlaylist(result) return this.parsers.parsePlaylist(result)
} }
}) }) as MediaItemTypeMap[T][]
} }
// Temporary implementation, I'll actually make something better later
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> { public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
SortBy: 'PlayCount', SortBy: 'PlayCount',
@@ -97,10 +100,9 @@ export class Jellyfin implements Connection {
limit: '10', limit: '10',
}) })
return this.services const mostPlayedResponse = await this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`).json<{ Items: JellyfinAPI.Song[] }>()
.request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>) return mostPlayedResponse.Items.map(this.parsers.parseSong)
.then((data) => data.Items.map((song) => this.services.parseSong(song)))
} }
// TODO: Figure out why seeking a jellyfin song takes so much longer than ytmusic (hls?) // TODO: Figure out why seeking a jellyfin song takes so much longer than ytmusic (hls?)
@@ -114,14 +116,11 @@ export class Jellyfin implements Connection {
userId: this.jellyfinUserId, userId: this.jellyfinUserId,
}) })
return this.services.request(`Audio/${id}/universal?${audoSearchParams.toString()}`, { headers, keepalive: true }) return this.api(`Audio/${id}/universal?${audoSearchParams.toString()}`, { headers, keepalive: true })
} }
public async getAlbum(id: string) { public async getAlbum(id: string) {
return this.services return this.api(`Users/${this.jellyfinUserId}/Items/${id}`).json<JellyfinAPI.Album>().then(this.parsers.parseAlbum)
.request(`/Users/${this.jellyfinUserId}/Items/${id}`)
.then((response) => response.json() as Promise<JellyfinAPI.Album>)
.then(this.services.parseAlbum)
} }
public async getAlbumItems(id: string) { public async getAlbumItems(id: string) {
@@ -130,17 +129,13 @@ export class Jellyfin implements Connection {
sortBy: 'ParentIndexNumber,IndexNumber,SortName', sortBy: 'ParentIndexNumber,IndexNumber,SortName',
}) })
return this.services return this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`) .json<{ Items: JellyfinAPI.Song[] }>()
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>) .then((response) => response.Items.map(this.parsers.parseSong))
.then((data) => data.Items.map(this.services.parseSong))
} }
public async getPlaylist(id: string) { public async getPlaylist(id: string) {
return this.services return this.api(`Users/${this.jellyfinUserId}/Items/${id}`).json<JellyfinAPI.Playlist>().then(this.parsers.parsePlaylist)
.request(`/Users/${this.jellyfinUserId}/Items/${id}`)
.then((response) => response.json() as Promise<JellyfinAPI.Playlist>)
.then(this.services.parsePlaylist)
} }
public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }) { public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }) {
@@ -152,65 +147,41 @@ export class Jellyfin implements Connection {
if (options?.startIndex) searchParams.append('startIndex', options.startIndex.toString()) if (options?.startIndex) searchParams.append('startIndex', options.startIndex.toString())
if (options?.limit) searchParams.append('limit', options.limit.toString()) if (options?.limit) searchParams.append('limit', options.limit.toString())
return this.services return this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`) .json<{ Items: JellyfinAPI.Song[] }>()
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>) .then((response) => response.Items.map(this.parsers.parseSong))
.then((data) => data.Items.map(this.services.parseSong))
} }
public static async authenticateByName(username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthenticationResponse> { public static async authenticateByName(username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthenticationResponse> {
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString() return ky
return fetch(authUrl, { .post(new URL('Users/AuthenticateByName', serverUrl.origin), {
method: 'POST',
body: JSON.stringify({
Username: username,
Pw: password,
}),
headers: { headers: {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="${PUBLIC_VERSION}"`, 'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="${PUBLIC_VERSION}"`,
}, },
json: {
Username: username,
Pw: password,
},
}) })
.catch(() => { .json<JellyfinAPI.AuthenticationResponse>()
throw new JellyfinFetchError('Could not reach Jellyfin Server', 400, authUrl)
})
.then((response) => {
if (!response.ok) throw new JellyfinFetchError('Failed to Authenticate', 401, authUrl)
return response.json() as Promise<JellyfinAPI.AuthenticationResponse>
})
} }
} }
class JellyfinServices { class JellyfinParsers {
private readonly connectionId: string private readonly connectionId: string
private readonly serverUrl: string
public readonly serverUrl: (endpoint?: string) => URL constructor(connectionId: string, serverUrl: string) {
public readonly request: (endpoint: string, options?: RequestInit) => Promise<Response>
constructor(connectionId: string, serverUrl: string, accessToken: string) {
this.connectionId = connectionId this.connectionId = connectionId
this.serverUrl = serverUrl
this.serverUrl = (endpoint?: string) => new URL(endpoint ?? '', serverUrl)
this.request = async (endpoint: string, options?: RequestInit) => {
const headers = new Headers(options?.headers)
headers.set('Authorization', `MediaBrowser Token="${accessToken}"`)
delete options?.headers
return fetch(this.serverUrl(endpoint), { headers, ...options }).then((response) => {
if (!response.ok) {
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.connectionId} experienced and internal server error`)
throw TypeError(`Client side error in request to jellyfin server of connection ${this.connectionId}`)
}
return response
})
}
} }
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder: string): string private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder: string): string
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined
private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined { private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined {
const imageItemId = item.ImageTags?.Primary ? item.Id : 'AlbumPrimaryImageTag' in item && item.AlbumPrimaryImageTag ? item.AlbumId : undefined const imageItemId = item.ImageTags?.Primary ? item.Id : 'AlbumPrimaryImageTag' in item && item.AlbumPrimaryImageTag ? item.AlbumId : undefined
return imageItemId ? this.serverUrl(`Items/${imageItemId}/Images/Primary`).toString() : placeholder return imageItemId ? new URL(`Items/${imageItemId}/Images/Primary`, this.serverUrl).toString() : placeholder
} }
public parseSong = (song: JellyfinAPI.Song): Song => ({ public parseSong = (song: JellyfinAPI.Song): Song => ({
@@ -255,122 +226,31 @@ class JellyfinServices {
class JellyfinLibraryManager { class JellyfinLibraryManager {
private readonly jellyfinUserId: string private readonly jellyfinUserId: string
private readonly services: JellyfinServices private readonly api: KyInstance
private readonly parsers: JellyfinParsers
constructor(jellyfinUserId: string, services: JellyfinServices) { constructor(jellyfinUserId: string, api: KyInstance, parsers: JellyfinParsers) {
this.jellyfinUserId = jellyfinUserId this.jellyfinUserId = jellyfinUserId
this.services = services this.api = api
this.parsers = parsers
} }
public async albums(): Promise<Album[]> { public async albums(): Promise<Album[]> {
return this.services return this.api(`Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=MusicAlbum&recursive=true`)
.request(`/Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=MusicAlbum&recursive=true`) .json<{ Items: JellyfinAPI.Album[] }>()
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Album[] }>) .then((response) => response.Items.map(this.parsers.parseAlbum))
.then((data) => data.Items.map(this.services.parseAlbum))
} }
public async artists(): Promise<Artist[]> { public async artists(): Promise<Artist[]> {
// ? This returns just album artists instead of all artists like in finamp, but I might decide that I want to return all artists instead // ? This returns just album artists instead of all artists like in finamp, but I might decide that I want to return all artists instead
return this.services return this.api('Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true')
.request('/Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true') .json<{ Items: JellyfinAPI.Artist[] }>()
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Artist[] }>) .then((response) => response.Items.map(this.parsers.parseArtist))
.then((data) => data.Items.map(this.services.parseArtist))
} }
public async playlists(): Promise<Playlist[]> { public async playlists(): Promise<Playlist[]> {
return this.services return this.api(`Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=Playlist&recursive=true`)
.request(`/Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=Playlist&recursive=true`) .json<{ Items: JellyfinAPI.Playlist[] }>()
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Playlist[] }>) .then((response) => response.Items.map(this.parsers.parsePlaylist))
.then((data) => data.Items.map(this.services.parsePlaylist))
}
}
export class JellyfinFetchError extends Error {
public httpCode: number
public url: string
constructor(message: string, httpCode: number, url: string) {
super(message)
this.httpCode = httpCode
this.url = url
}
}
declare namespace JellyfinAPI {
type Song = {
Name: string
Id: string
Type: 'Audio'
RunTimeTicks: number
PremiereDate?: string
ProductionYear?: number
ArtistItems?: {
Name: string
Id: string
}[]
Album?: string
AlbumId?: string
AlbumPrimaryImageTag?: string
AlbumArtists?: {
Name: string
Id: string
}[]
ImageTags?: {
Primary?: string
}
}
type Album = {
Name: string
Id: string
Type: 'MusicAlbum'
RunTimeTicks: number
PremiereDate?: string
ProductionYear?: number
ArtistItems?: {
Name: string
Id: string
}[]
AlbumArtists?: {
Name: string
Id: string
}[]
ImageTags?: {
Primary?: string
}
}
type Artist = {
Name: string
Id: string
Type: 'MusicArtist'
ImageTags?: {
Primary?: string
}
}
type Playlist = {
Name: string
Id: string
Type: 'Playlist'
RunTimeTicks: number
ChildCount: number
ImageTags?: {
Primary?: string
}
}
interface UserResponse {
Name: string
Id: string
}
interface AuthenticationResponse {
User: JellyfinAPI.UserResponse
AccessToken: string
}
interface SystemResponse {
ServerName: string
} }
} }

View File

@@ -22,6 +22,44 @@
// NEW NOTE: hq720 is the same as maxresdefault. If an hq720 image is returned we don't need to query the v3 api // NEW NOTE: hq720 is the same as maxresdefault. If an hq720 image is returned we don't need to query the v3 api
export namespace InnerTube { export namespace InnerTube {
interface ErrorResponse {
error: {
code: number
message: string
status: string // 'NOT_FOUND' - Id does not exist | 'INVALID_ARGUMENT' - Invalid Id, potenially for unavailable videos | 'INTERNAL' - YouTube had a stroke
}
}
// For response made to the browse endpoint with the user's id
namespace User {
interface Response {
contents: unknown // Whole lot of cool stuff in here that I may want to use at some point
header: {
musicVisualHeaderRenderer: {
title: {
runs: [
{
text: string // Username
},
]
}
thumbnail: unknown // Contains banner art
foregroundThumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
}
}
}
}
namespace Library { namespace Library {
interface AlbumResponse { interface AlbumResponse {
contents: { contents: {
@@ -379,14 +417,6 @@ export namespace InnerTube {
} }
} }
interface ErrorResponse {
error: {
code: number
message: string
status: string
}
}
type MusicResponsiveHeaderRenderer = { type MusicResponsiveHeaderRenderer = {
thumbnail: { thumbnail: {
musicThumbnailRenderer: { musicThumbnailRenderer: {
@@ -473,7 +503,8 @@ export namespace InnerTube {
runs: [ runs: [
{ {
text: string // Song Name text: string // Song Name
navigationEndpoint: { navigationEndpoint?: {
// This will be missing if the song is not playable
watchEndpoint: { watchEndpoint: {
videoId: string videoId: string
watchEndpointMusicSupportedConfigs: { watchEndpointMusicSupportedConfigs: {
@@ -660,14 +691,6 @@ export namespace InnerTube {
} }
} }
interface ErrorResponse {
error: {
code: number
message: string
status: string
}
}
type MusicResponsiveListItemRenderer = { type MusicResponsiveListItemRenderer = {
flexColumns: [ flexColumns: [
{ {
@@ -746,7 +769,7 @@ export namespace InnerTube {
interface PlayerErrorResponse { interface PlayerErrorResponse {
playabilityStatus: { playabilityStatus: {
status: 'ERROR' status: 'ERROR'
reason: string reason: string // 'This video is unavailable' - Could be invalid video id / private / blocked
} }
} }
@@ -766,18 +789,18 @@ export namespace InnerTube {
queueDatas: Array<{ queueDatas: Array<{
content: content:
| { | {
playlistPanelVideoRenderer: PlaylistPanelVideoRenderer // This occurs when the playlist item does not have a video or auto-generated counterpart playlistPanelVideoRenderer: PlaylistPanelVideoRenderer | BlockedPlaylistPanelVideoRenderer // This occurs when the playlist item does not have a video or auto-generated counterpart
} }
| { | {
playlistPanelVideoWrapperRenderer: { playlistPanelVideoWrapperRenderer: {
// This occurs when the playlist has a video or auto-generated counterpart // This occurs when the playlist has a video or auto-generated counterpart
primaryRenderer: { primaryRenderer: {
playlistPanelVideoRenderer: PlaylistPanelVideoRenderer playlistPanelVideoRenderer: PlaylistPanelVideoRenderer | BlockedPlaylistPanelVideoRenderer
} }
counterpart: [ counterpart: [
{ {
counterpartRenderer: { counterpartRenderer: {
playlistPanelVideoRenderer: PlaylistPanelVideoRenderer playlistPanelVideoRenderer: PlaylistPanelVideoRenderer | BlockedPlaylistPanelVideoRenderer
} }
}, },
] ]
@@ -786,14 +809,6 @@ export namespace InnerTube {
}> }>
} }
interface ErrorResponse {
error: {
code: number
message: string
status: string
}
}
type PlaylistPanelVideoRenderer = { type PlaylistPanelVideoRenderer = {
title: { title: {
runs: [ runs: [
@@ -842,11 +857,789 @@ export namespace InnerTube {
} }
} }
} }
type BlockedPlaylistPanelVideoRenderer = {
thumbnail: {
thumbnails: [
{
url: string
},
]
}
navigationEndpoint: {
watchEndpoint: {
videoId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_ATV' | 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC'
}
}
}
}
unplayableText: {
runs: [
{
text: string
},
]
}
videoId: string
}
} }
// TODO: Need to fix this & it's corresponding method & add appropriate namespace namespace Search {
interface SearchResponse { interface Response {
contents: unknown contents: {
tabbedSearchResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: Array<
| {
itemSectionRenderer: unknown
}
| {
musicCardShelfRenderer: SongMusicCardShelfRenderer | VideoMusicCardShelfRenderer | AlbumMusicCardShelfRenderer | ArtistMusicCardShelfRenderer // I have not seen any other types of cards
}
| {
musicShelfRenderer:
| SongsMusicShelfRenderer
| VideosMusicShelfRenderer
| AlbumsMusicShelfRenderer
| CommunityPlaylistsMusicShelfRenderer
| ArtistsMusicShelfRenderer
| PodcastsMusicShelfRenderer
| EpisodesMusicShelfRenderer
| ProfilesMusicShelfRenderer
}
>
}
}
}
},
// There is a library tab when no filter is specified, but I don't plan on utilizing it anyway
]
}
}
}
type SongMusicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string // Duration will be in one of these
navigationEndpoint: {
watchEndpoint: {
videoId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_ATV'
}
}
}
}
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
}>
}
}
},
// There is a third musicResponsiveListItemFlexColumnRenderer but it only contains view count
]
}
type VideoMusicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
navigationEndpoint?: {
watchEndpoint: {
videoId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC'
}
}
}
}
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
text: string // Duration will be in one of these
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}
}
}>
}
}
},
]
}
type AlbumMusicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
text: string // Release year will be in one of these
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
}>
}
}
},
]
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM'
}
}
}
}
}
type CommunityPlaylistMusicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}
}
}>
}
}
},
]
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_PLAYLIST'
}
}
}
}
}
type ArtistMusicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
// Nothing Useful in here
text: string
}>
}
}
},
]
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
}
type EpisodeMusicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string // This is the id to get to the episode's page
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE'
}
}
}
}
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE'
}
}
}
}
}>
}
}
},
]
playlistItemData: {
videoId: string // This is the id to actually play the video
}
}
type SongMusicCardShelfRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string
navigationEndpoint: {
watchEndpoint: {
videoId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_ATV'
}
}
}
}
},
]
}
subtitle: {
runs: Array<{
text: string // The duration string will also be in here
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
}>
}
contents?: Array<
| {
messageRenderer: unknown
}
| {
musicResponsiveListItemRenderer: SongMusicResponsiveListItemRenderer | VideoMusicResponsiveListItemRenderer // I'm not sure if any other types can appear in these
}
>
}
type VideoMusicCardShelfRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string
navigationEndpoint: {
watchEndpoint: {
videoId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC'
}
}
}
}
},
]
}
subtitle: {
runs: Array<{
text: string // The duration string will also be in here
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}
}
}>
}
}
type AlbumMusicCardShelfRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM'
}
}
}
}
},
]
}
subtitle: {
runs: Array<{
text: string // "Various Artists" may take place of any run with a navigation endpoint
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
}>
}
}
type ArtistMusicCardShelfRenderer = {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
title: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ARTIST'
}
}
}
}
},
]
}
// Nothing in the subtitle is useful
contents: Array<{
// I have yet to run into a scenario where an artist card did not have contents
musicResponsiveListItemRenderer: SongMusicResponsiveListItemRenderer
}>
}
type SongsMusicShelfRenderer = {
title: {
runs: [
{
text: 'Songs'
},
]
}
contents: Array<{
musicResponsiveListItemRenderer: SongMusicResponsiveListItemRenderer
}>
}
type VideosMusicShelfRenderer = {
title: {
runs: [
{
text: 'Videos'
},
]
}
contents: Array<{
// For some reason episodes can sometimes show up video sections. Because why have any sort of consistency in your app? FML
musicResponsiveListItemRenderer: VideoMusicResponsiveListItemRenderer | EpisodeMusicResponsiveListItemRenderer
}>
}
type AlbumsMusicShelfRenderer = {
title: {
runs: [
{
text: 'Albums'
},
]
}
contents: Array<{
musicResponsiveListItemRenderer: AlbumMusicResponsiveListItemRenderer
}>
}
type CommunityPlaylistsMusicShelfRenderer = {
title: {
runs: [
{
text: 'Community playlists'
},
]
}
contents: Array<{
musicResponsiveListItemRenderer: CommunityPlaylistsMusicResponsiveListItemRenderer
}>
}
type ArtistsMusicShelfRenderer = {
title: {
runs: [
{
text: 'Artists'
},
]
}
contents: Array<{
musicResponsiveListItemRenderer: ArtistMusicResponsiveListItemRenderer
}>
}
type PodcastsMusicShelfRenderer = {
title: {
runs: [
{
text: 'Podcasts'
},
]
}
contents: Array<{
musicResponsiveListItemRenderer: {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}
}
}>
}
}
},
]
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE'
}
}
}
}
}
}>
}
type EpisodesMusicShelfRenderer = {
title: {
runs: [
{
text: 'Episodes'
},
]
}
contents: Array<{
musicResponsiveListItemRenderer: EpisodeMusicResponsiveListItemRenderer
}>
}
type ProfilesMusicShelfRenderer = {
title: {
runs: [
{
text: 'Profiles'
},
]
}
contents: Array<{
musicResponsiveListItemRenderer: {
thumbnail: {
musicThumbnailRenderer: {
thumbnail: {
thumbnails: Array<{
url: string
width: number
height: number
}>
}
}
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: Array<{
text: string
}>
}
}
},
]
navigationEndpoint: {
browseEndpoint: {
browseId: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL'
}
}
}
}
}
}>
}
} }
// TODO: Need to fix this & it's corresponding method & add appropriate namespace // TODO: Need to fix this & it's corresponding method & add appropriate namespace
@@ -854,3 +1647,60 @@ export namespace InnerTube {
contents: unknown contents: unknown
} }
} }
export namespace YouTubeDataApi {
namespace PlaylistItems {
type Response<P extends 'snippet' | 'contentDetails' | 'status'> = {
nextPageToken?: string
prevPageToken?: string
items: Array<Item<P>>
pageInfo: {
totalResults: number
resultsPerPage: number
}
}
type Item<P extends 'snippet' | 'contentDetails' | 'status'> = {
id: string
} & {
[K in P]: K extends 'snippet' ? Snippet : K extends 'contentDetails' ? ContentDetails : K extends 'status' ? Status : never
}
type Snippet = {
publishedAt: string
channelId: string
title: string
description: string
thumbnails: {
default: Thumbnail
medium: Thumbnail
high: Thumbnail
standard?: Thumbnail
maxres?: Thumbnail
}
channelTitle: string
playlistId: string
position: number
resourceId: {
videoId: string
}
videoOwnerChannelTitle?: string
videoOwnerChannelId?: string
}
type ContentDetails = {
videoId: string
videoPublishedAt: string
}
type Status = {
privacyStatus: string
}
type Thumbnail = {
url: string
width: number
height: number
}
}
}

View File

@@ -1,44 +1,40 @@
import { youtube, type youtube_v3 } from 'googleapis/build/src/apis/youtube'
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'
import type { InnerTube } from './youtube-music-types' import type { InnerTube, YouTubeDataApi } from './youtube-music-types'
import { DB } from './db' import { DB } from './db'
import ky, { type KyInstance } from 'ky'
const ytDataApi = youtube('v3') // TODO: At some point I want to ditch this package and just make the API calls directly. Fewer dependecies
// TODO: Throughout this method, whenever I extract the duration of a video I might want to subtract 1, the actual duration appears to always be one second less than what the duration lists.
export class YouTubeMusic implements Connection { export class YouTubeMusic implements Connection {
public readonly id: string public readonly id: string
private readonly userId: string private readonly userId: string
private readonly youtubeUserId: string private readonly youtubeUserId: string
private readonly requestManager: YTRequestManager private readonly api: APIManager
private libraryManager?: YTLibaryManager private libraryManager?: LibaryManager
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) { constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
this.id = id this.id = id
this.userId = userId this.userId = userId
this.youtubeUserId = youtubeUserId this.youtubeUserId = youtubeUserId
this.requestManager = new YTRequestManager(id, accessToken, refreshToken, expiry) this.api = new APIManager(id, accessToken, refreshToken, expiry)
} }
public get library() { public get library() {
if (!this.libraryManager) this.libraryManager = new YTLibaryManager(this.id, this.youtubeUserId, this.requestManager) if (!this.libraryManager) this.libraryManager = new LibaryManager(this.id, this.youtubeUserId, this.api)
return this.libraryManager return this.libraryManager
} }
// * This method can NOT throw an error
public async getConnectionInfo() { public async getConnectionInfo() {
const access_token = await this.requestManager.accessToken.catch(() => null) const response = await this.api.v1
.WEB_REMIX('browse', { json: { browseId: this.youtubeUserId } })
.json<InnerTube.User.Response>()
.catch(() => null)
let username: string | undefined, profilePicture: string | undefined const username = response?.header.musicVisualHeaderRenderer.title.runs[0].text
if (access_token) { const profilePicture = response ? extractLargestThumbnailUrl(response.header.musicVisualHeaderRenderer.foregroundThumbnail.musicThumbnailRenderer.thumbnail.thumbnails) : undefined
const userChannelResponse = await ytDataApi.channels.list({ mine: true, part: ['snippet'], access_token })
const userChannel = userChannelResponse?.data.items?.[0]
username = userChannel?.snippet?.title ?? undefined
profilePicture = userChannel?.snippet?.thumbnails?.default?.url ?? undefined
}
return { return {
id: this.id, id: this.id,
@@ -50,29 +46,265 @@ export class YouTubeMusic implements Connection {
} satisfies ConnectionInfo } satisfies ConnectionInfo
} }
// ! Need to completely rework this method - Currently returns empty array public async search<T extends keyof MediaItemTypeMap>(searchTerm: string, types: Set<T>): Promise<MediaItemTypeMap[T][]> {
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)[]> {
const searchFilterParams = { const searchFilterParams = {
song: 'EgWKAQIIAWoMEA4QChADEAQQCRAF', song: 'EgWKAQIIAWoMEA4QChADEAQQCRAF',
video: 'EgWKAQIQAWoMEA4QChADEAQQCRAF',
album: 'EgWKAQIYAWoMEA4QChADEAQQCRAF', album: 'EgWKAQIYAWoMEA4QChADEAQQCRAF',
artist: 'EgWKAQIgAWoMEA4QChADEAQQCRAF', artist: 'EgWKAQIgAWoMEA4QChADEAQQCRAF',
playlist: 'Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D', playlist: 'EgeKAQQoAEABagwQDhAKEAMQBBAJEAU%3D',
} as const } as const
return [] // /search && { body: { query: searchTerm, params: searchFilterParams[filter] } } const searchType = async (type: keyof typeof searchFilterParams) => this.api.v1.WEB_REMIX('search', { json: { query: searchTerm, params: searchFilterParams[type] } }).json<InnerTube.Search.Response>()
const extendedTypes = new Set<keyof typeof searchFilterParams>(types)
if (extendedTypes.has('song')) extendedTypes.add('video')
const searchResponses = await Promise.all(Array.from(extendedTypes, searchType))
// Ok so I have a problem here. Firstly, the youtube music flavor of search is fucking abyssmal. You get like at most 3 results for each type of
// content and most of it is completely irrelavent and not even close to what you search for. On top of that it won't even try to return
// livestreams or past completed broadcasts. The standard youtube search is far superior however the pain point there is you are either getting
// a video (which includes livestream content), a channel, or a playlist, which does not line up super well with the current Song, Album, Artist
// Playlist architecture. For non-livestream videos I could put the results throught the getSongs() method which will scrape the counterparts,
// but there is really no way to get albums or or determine if a channel is an artist or an uploader. I guess I could query both with filters but
// the minimum of like five API calls + the parsing just sounds like such an bad time.
// Now that I think about it, I don't really know how I want to do search. Returning finite results would make my life easier but I think that's
// just not a good idea. Acutally it seems most streaming services do limit the number of seach results. Only problem is, IDK how I'm supposed to
// implement a limt query param in the search endpoint when the I'm getting all of the content from many different APIs, *some of which* (fucking yt music),
// don't provide a way to limit the number of results returned
// Holy fuck it gets even worse. The v3 API does not even allow you get anything beyond the "snippet" for completed live streams, meaning no duration or high res
// thumbnails (at most it returns like a 360p). On top of that we can't use the getSongs() method either because it will return an error response (INVALID_ARGUMENT).
// I think I'm just going to have to bite the bullet query both the YTMusic API and the standard youtube search v1 API.
// NOTE:
// To ensure best result we want to make sure we are only getting videos relavent to the search back. Youtube for some reason throws so much bs in their default search results
// like "For You" and "People also Watched", which is almost never relevant to the actual search. To fix this just include the param EgIQAQ%3D%3D in the body of the request.
// This way it should only return a list of videos that are relevant to the actual search, including past completed broadcasts and excluding active livestream (which we want).
// Also, don't try to get playlists from the default search, we want to get those from the YTMusic API so we get those nice 2x2 album art thumbnails
// Brand new problems. With the standard YT Video search there is no way to determine if the channel that uploaded it is an artist or just an uploader channel.
// Additionally, ytmusic appears to try to filter results for videos that are music oriented. This does not always work perfectly, especially if you search for something
// inherently non-musical, but it means that generally results are more likely to be music related than with the standard YT search.
// Y'know what, I'm just not going to worry about this now.
const parseSongAndVideoCardShelfRenderer = (card: InnerTube.Search.SongMusicCardShelfRenderer | InnerTube.Search.VideoMusicCardShelfRenderer): Song => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const id = card.title.runs[0].navigationEndpoint.watchEndpoint.videoId
const name = card.title.runs[0].text
const isVideo = card.title.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const duration = timestampToSeconds(card.subtitle.runs.find((run) => /^(\d{1,}:\d{2}:\d{2}|\d{1,2}:\d{2})$/.test(run.text))!.text)
const thumbnailUrl = extractLargestThumbnailUrl(card.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Song['artists'], album: Song['album'], uploader: Song['uploader']
card.subtitle.runs.forEach((run) => {
if (!run.navigationEndpoint) return
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
album = runData
break
case 'MUSIC_PAGE_TYPE_ARTIST':
artists ? artists.push(runData) : (artists = [runData])
break
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
uploader = runData
break
}
})
return { connection, id, name, type: 'song', duration, thumbnailUrl, artists, album, uploader, isVideo }
}
// Returns null if the video is not playable or if it is an Episode (not currently supported)
// ? The videos filter for YTMusic search only returns sddefault images at most. Might just want to scrape the id an then use getSongs()
const parseSongAndVideoResponsiveListItemRenderer = (
item: InnerTube.Search.SongMusicResponsiveListItemRenderer | InnerTube.Search.VideoMusicResponsiveListItemRenderer | InnerTube.Search.EpisodeMusicResponsiveListItemRenderer,
): Song | null => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const col1 = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0]
const col2runs = item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs
if (!col1.navigationEndpoint || 'browseEndpoint' in col1.navigationEndpoint) return null
const id = col1.navigationEndpoint.watchEndpoint.videoId
const name = col1.text
const isVideo = col1.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const duration = timestampToSeconds(col2runs.find((run) => /^(\d{1,}:\d{2}:\d{2}|\d{1,2}:\d{2})$/.test(run.text))!.text)
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Song['artists'], album: Song['album'], uploader: Song['uploader']
col2runs.forEach((run) => {
if (!run.navigationEndpoint) return
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
album = runData
break
case 'MUSIC_PAGE_TYPE_ARTIST':
artists ? artists.push(runData) : (artists = [runData])
break
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
uploader = runData
break
}
})
return { connection, id, name, type: 'song', duration, thumbnailUrl, artists, album, uploader, isVideo }
}
const parseAlbumCardShelfRenderer = (card: InnerTube.Search.AlbumMusicCardShelfRenderer): Album => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
const id = card.title.runs[0].navigationEndpoint.browseEndpoint.browseId
const name = card.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(card.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = 'Various Artists',
releaseYear: string | undefined
card.subtitle.runs.forEach((run) => {
if (run.navigationEndpoint) {
const artistData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
typeof artists === 'string' ? (artists = [artistData]) : artists.push(artistData)
} else if (/^\d{4}$/.test(run.text)) {
releaseYear = run.text
}
})
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear }
}
const parseArtistCardShelfRenderer = (card: InnerTube.Search.ArtistMusicCardShelfRenderer): Artist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
const id = card.title.runs[0].navigationEndpoint.browseEndpoint.browseId
const name = card.title.runs[0].text
const profilePicture = extractLargestThumbnailUrl(card.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture }
}
const parseAlbumResponsiveListItemRenderer = (item: InnerTube.Search.AlbumMusicResponsiveListItemRenderer): Album => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId
const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = 'Various Artists',
releaseYear: Album['releaseYear']
item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.forEach((run) => {
if (run.navigationEndpoint) {
const artistData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
typeof artists === 'string' ? (artists = [artistData]) : artists.push(artistData)
} else if (/^\d{4}$/.test(run.text)) {
releaseYear = run.text
}
})
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear }
}
const parseArtistResponsiveListItemRenderer = (item: InnerTube.Search.ArtistMusicResponsiveListItemRenderer): Artist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId
const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const profilePicture = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture }
}
const parseCommunityPlaylistResponsiveListItemRenderer = (item: InnerTube.Search.CommunityPlaylistMusicResponsiveListItemRenderer): Playlist => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
const id = item.navigationEndpoint.browseEndpoint.browseId
const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
let createdBy: Playlist['createdBy']
item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.forEach((run) => {
if (!run.navigationEndpoint) return
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy }
}
const contents = searchResponses.map((response) => response.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents).flat()
const cardSections = contents.filter((section) => 'musicCardShelfRenderer' in section)
const shelveSections = contents.filter((section) => 'musicShelfRenderer' in section)
const extractedItems: (Song | Album | Artist | Playlist)[] = []
for (const section of cardSections) {
if ('watchEndpoint' in section.musicCardShelfRenderer.title.runs[0].navigationEndpoint) {
const card = section.musicCardShelfRenderer as InnerTube.Search.SongMusicCardShelfRenderer | InnerTube.Search.VideoMusicCardShelfRenderer
extractedItems.push(parseSongAndVideoCardShelfRenderer(card))
if (!('contents' in card && card.contents)) continue
const playableContents = card.contents.filter((item) => 'musicResponsiveListItemRenderer' in item)
const contentSongs = playableContents.map((item) => parseSongAndVideoResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)).filter((song) => song !== null)
extractedItems.push(...contentSongs)
} else {
const sectionType = section.musicCardShelfRenderer.title.runs[0].navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
if (sectionType === 'MUSIC_PAGE_TYPE_ALBUM') {
const card = section.musicCardShelfRenderer as InnerTube.Search.AlbumMusicCardShelfRenderer
extractedItems.push(parseAlbumCardShelfRenderer(card))
} else {
const card = section.musicCardShelfRenderer as InnerTube.Search.ArtistMusicCardShelfRenderer
card.contents.forEach((content) => {
const song = parseSongAndVideoResponsiveListItemRenderer(content.musicResponsiveListItemRenderer)
if (song) extractedItems.push(song)
})
extractedItems.push(parseArtistCardShelfRenderer(card))
}
}
}
for (const section of shelveSections) {
switch (section.musicShelfRenderer.title.runs[0].text) {
case 'Songs':
case 'Videos':
const songShelf = section.musicShelfRenderer as InnerTube.Search.SongsMusicShelfRenderer | InnerTube.Search.VideosMusicShelfRenderer
const songs = songShelf.contents.map((item) => parseSongAndVideoResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)).filter((song) => song !== null)
extractedItems.push(...songs)
break
case 'Albums':
const albumShelf = section.musicShelfRenderer as InnerTube.Search.AlbumsMusicShelfRenderer
const albums = albumShelf.contents.map((item) => parseAlbumResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
extractedItems.push(...albums)
break
case 'Artists':
const artistShelf = section.musicShelfRenderer as InnerTube.Search.ArtistsMusicShelfRenderer
const artists = artistShelf.contents.map((item) => parseArtistResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
extractedItems.push(...artists)
break
case 'Community playlists':
const playlistShelf = section.musicShelfRenderer as InnerTube.Search.CommunityPlaylistsMusicShelfRenderer
const playlists = playlistShelf.contents.map((item) => parseCommunityPlaylistResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
extractedItems.push(...playlists)
break
}
}
return extractedItems.filter((item): item is MediaItemTypeMap[T] => types.has(item.type as T))
} }
// ! Need to completely rework this method - Currently returns empty array // ! Need to completely rework this method - Currently returns empty array
public async getRecommendations() { public async getRecommendations() {
return [] // browseId: 'FEmusic_home' // const response = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_home' } }).json()
// console.log(JSON.stringify(response))
return []
} }
// TODO: Move to innerTubeFetch method
public async getAudioStream(id: string, headers: Headers) { public async getAudioStream(id: string, headers: Headers) {
if (!isValidVideoId(id)) throw TypeError('Invalid youtube video Id') if (!isValidVideoId(id)) throw TypeError('Invalid youtube video Id')
@@ -87,35 +319,7 @@ export class YouTubeMusic implements Connection {
// * MASSIVE props and credit to Oleksii Holub for documenting the android client method of player fetching (See refrences at bottom). // * MASSIVE props and credit to Oleksii Holub for documenting the android client method of player fetching (See refrences at bottom).
// * Go support him and go support Ukraine (he's Ukrainian) // * Go support him and go support Ukraine (he's Ukrainian)
const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', { const playerResponse = await this.api.v1.ANDROID_TESTSUITE('player', { json: { videoId: id } }).json<InnerTube.Player.PlayerResponse>()
headers: {
// 'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', <-- I thought this was necessary but it appears it might not be?
authorization: `Bearer ${await this.requestManager.accessToken}`, // * Including the access token is what enables access to premium content for some reason
},
method: 'POST',
body: JSON.stringify({
videoId: id,
context: {
client: {
clientName: 'ANDROID_TESTSUITE',
clientVersion: '1.9',
// androidSdkVersion: 30, <-- I thought this was necessary but it appears it might not be?
},
},
}),
})
.then((response) => response.json() as Promise<InnerTube.Player.PlayerResponse | InnerTube.Player.PlayerErrorResponse>)
.catch(() => null)
if (!playerResponse) throw Error(`Failed to fetch player for song ${id} of connection ${this.id}`)
if (!('streamingData' in playerResponse)) {
if (playerResponse.playabilityStatus.reason === 'This video is unavailable') throw TypeError('Invalid youtube video Id')
const errorMessage = `Unknown player response error: ${playerResponse.playabilityStatus.reason}`
console.error(errorMessage)
throw Error(errorMessage)
}
const formats = playerResponse.streamingData.formats?.concat(playerResponse.streamingData.adaptiveFormats ?? []) const formats = playerResponse.streamingData.formats?.concat(playerResponse.streamingData.adaptiveFormats ?? [])
const audioOnlyFormats = formats?.filter( const audioOnlyFormats = formats?.filter(
@@ -142,20 +346,7 @@ export class YouTubeMusic implements Connection {
* @param id The browseId of the album * @param id The browseId of the album
*/ */
public async getAlbum(id: string): Promise<Album> { public async getAlbum(id: string): Promise<Album> {
const albumResponse = await this.requestManager const albumResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: id } }).json<InnerTube.Album.AlbumResponse>()
.innerTubeFetch('/browse', { body: { browseId: id } })
.then((response) => response.json() as Promise<InnerTube.Album.AlbumResponse | InnerTube.Album.ErrorResponse>)
.catch(() => null)
if (!albumResponse) throw Error(`Failed to fetch album ${id} of connection ${this.id}`)
if ('error' in albumResponse) {
if (albumResponse.error.status === 'NOT_FOUND' || albumResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube album id')
const errorMessage = `Unknown playlist response error: ${albumResponse.error.message}`
console.error(errorMessage)
throw Error(errorMessage)
}
const header = albumResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer const header = albumResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer
@@ -182,122 +373,28 @@ export class YouTubeMusic implements Connection {
* @param id The browseId of the album * @param id The browseId of the album
*/ */
public async getAlbumItems(id: string): Promise<Song[]> { public async getAlbumItems(id: string): Promise<Song[]> {
const albumResponse = await this.requestManager const albumResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: id } }).json<InnerTube.Album.AlbumResponse>()
.innerTubeFetch('/browse', { body: { browseId: id } })
.then((response) => response.json() as Promise<InnerTube.Album.AlbumResponse | InnerTube.Album.ErrorResponse>)
.catch(() => null)
if (!albumResponse) throw Error(`Failed to fetch album ${id} of connection ${this.id}`)
if ('error' in albumResponse) {
if (albumResponse.error.status === 'NOT_FOUND' || albumResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube album id')
const errorMessage = `Unknown playlist response error: ${albumResponse.error.message}`
console.error(errorMessage)
throw Error(errorMessage)
}
const header = albumResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer
const contents = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicShelfRenderer.contents const contents = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicShelfRenderer.contents
let continuation = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.continuations?.[0].nextContinuationData.continuation let continuation = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.continuations?.[0].nextContinuationData.continuation
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
const album: Song['album'] = { id, name: header.title.runs[0].text }
const artistMap = new Map<string, { name: string; profilePicture?: string }>()
header.straplineTextOne.runs.forEach((run, index) => {
if (run.navigationEndpoint) {
const profilePicture = index === 0 && header.straplineThumbnail ? extractLargestThumbnailUrl(header.straplineThumbnail.musicThumbnailRenderer.thumbnail.thumbnails) : undefined
artistMap.set(run.navigationEndpoint.browseEndpoint.browseId, { name: run.text, profilePicture })
}
})
const albumArtists = Array.from(artistMap, (artist) => ({ id: artist[0], name: artist[1].name, profilePicture: artist[1].profilePicture }))
while (continuation) { while (continuation) {
const continuationResponse = await this.requestManager const continuationResponse = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Album.ContinuationResponse>()
.innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`)
.then((response) => response.json() as Promise<InnerTube.Album.ContinuationResponse>)
.catch(() => null)
if (!continuationResponse) throw Error(`Failed to fetch album ${id} of connection ${this.id}`)
contents.push(...continuationResponse.continuationContents.musicShelfRenderer.contents) contents.push(...continuationResponse.continuationContents.musicShelfRenderer.contents)
continuation = continuationResponse.continuationContents.musicShelfRenderer.continuations?.[0].nextContinuationData.continuation continuation = continuationResponse.continuationContents.musicShelfRenderer.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 playableIds = contents.map((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId)
const playableItems = contents.filter((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined)
const dividedItems = [] return this.getSongs(playableIds)
for (let i = 0; i < playableItems.length; i += 50) dividedItems.push(playableItems.slice(i, i + 50))
const access_token = await this.requestManager.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, { id: string; name: string }>()
videoSchemas.forEach((video) => videoChannelMap.set(video.id!, { id: video.snippet?.channelId!, name: video.snippet?.channelTitle! }))
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)
let artists: Song['artists']
if (!col1.musicResponsiveListItemFlexColumnRenderer.text.runs) {
artists = albumArtists
} else {
col1.musicResponsiveListItemFlexColumnRenderer.text.runs.forEach((run) => {
if (run.navigationEndpoint) {
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist])
}
})
}
const uploader: Song['uploader'] = artists ? undefined : videoChannelMap.get(id)!
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, uploader, isVideo }
})
} }
/** /**
* @param id The id of the playlist (not the browseId!). * @param id The id of the playlist (not the browseId!).
*/ */
public async getPlaylist(id: string): Promise<Playlist> { public async getPlaylist(id: string): Promise<Playlist> {
const playlistResponse = await this.requestManager const playlistResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'VL'.concat(id) } }).json<InnerTube.Playlist.Response>()
.innerTubeFetch('/browse', { body: { browseId: 'VL'.concat(id) } })
.then((response) => response.json() as Promise<InnerTube.Playlist.Response | InnerTube.Playlist.ErrorResponse>)
.catch(() => null)
if (!playlistResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`)
if ('error' in playlistResponse) {
if (playlistResponse.error.status === 'NOT_FOUND' || playlistResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube playlist id')
const errorMessage = `Unknown playlist response error: ${playlistResponse.error.message}`
console.error(errorMessage)
throw Error(errorMessage)
}
const header = const header =
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0] 'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0]
@@ -327,141 +424,71 @@ export class YouTubeMusic implements Connection {
* @param limit The maximum number of playlist items to return * @param limit The maximum number of playlist items to return
*/ */
public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }): Promise<Song[]> { public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }): Promise<Song[]> {
const startIndex = options?.startIndex, const startIndex = options?.startIndex ?? 0,
limit = options?.limit limit = options?.limit ?? Infinity
const playlistResponse = await this.requestManager const playlistItemSearchParams = new URLSearchParams({
.innerTubeFetch('/browse', { body: { browseId: 'VL'.concat(id) } }) playlistId: id,
.then((response) => response.json() as Promise<InnerTube.Playlist.Response | InnerTube.Playlist.ErrorResponse>) maxResults: '50',
.catch(() => null) part: 'snippet,contentDetails,status',
if (!playlistResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`)
if ('error' in playlistResponse) {
if (playlistResponse.error.status === 'NOT_FOUND' || playlistResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube playlist id')
const errorMessage = `Unknown playlist items response error: ${playlistResponse.error.message}`
console.error(errorMessage)
throw Error(errorMessage)
}
const playableContents = playlistResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents.filter(
(item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined,
)
let continuation = playlistResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation
while (continuation && (!limit || playableContents.length < (startIndex ?? 0) + limit)) {
const continuationResponse = await this.requestManager
.innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`)
.then((response) => response.json() as Promise<InnerTube.Playlist.ContinuationResponse>)
.catch(() => null)
if (!continuationResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`)
const playableContinuationContents = continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents.filter(
(item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined,
)
playableContents.push(...playableContinuationContents)
continuation = continuationResponse.continuationContents.musicPlaylistShelfContinuation.continuations?.[0].nextContinuationData.continuation
}
const scrapedItems = playableContents.slice(startIndex ?? 0, limit ? (startIndex ?? 0) + limit : undefined).map((item) => {
const [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns
const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.videoId
const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
const col2run = col2.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
const album: Song['album'] = col2run ? { id: col2run.navigationEndpoint.browseEndpoint.browseId, name: col2run.text } : undefined
let artists: { id?: string; name: string }[] | undefined = [],
uploader: { id?: string; name: string } | undefined
for (const run of col1.musicResponsiveListItemFlexColumnRenderer.text.runs) {
const pageType = run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const runData = { id: run.navigationEndpoint?.browseEndpoint.browseId, name: run.text }
pageType === 'MUSIC_PAGE_TYPE_ARTIST' ? artists.push(runData) : (uploader = runData)
}
if (artists.length === 0) artists = undefined
return { id, name, duration, thumbnailUrl, artists, album, uploader, isVideo }
}) })
const dividedItems = [] const playableItems: YouTubeDataApi.PlaylistItems.Item<'snippet' | 'contentDetails' | 'status'>[] = []
for (let i = 0; i < scrapedItems.length; i += 50) dividedItems.push(scrapedItems.slice(i, i + 50)) while (playableItems.length < startIndex + limit) {
const itemsResponse = await this.api.v3(`playlistItems?${playlistItemSearchParams.toString()}`).json<YouTubeDataApi.PlaylistItems.Response<'snippet' | 'contentDetails' | 'status'>>()
const access_token = await this.requestManager.accessToken playableItems.push(...itemsResponse.items.filter((item) => item.status.privacyStatus === 'public' || item.snippet.videoOwnerChannelId === item.snippet.channelId))
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'] if (!itemsResponse.nextPageToken) break // Reached the end of the playlist, retrieved all items
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! playlistItemSearchParams.set('pageToken', itemsResponse.nextPageToken)
}
const thumbnailUrl = existingThumbnail ?? videoThumbnails.maxres?.url ?? videoThumbnails.standard?.url ?? videoThumbnails.high?.url ?? videoThumbnails.medium?.url ?? videoThumbnails.default?.url! const slicedItems = playableItems.slice(startIndex, startIndex + limit) // Removes over-fetch
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 const releaseDateMap = new Map<string, string>()
}) slicedItems.forEach((item) =>
releaseDateMap.set(item.contentDetails.videoId, new Date(item.snippet.description.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? item.contentDetails.videoPublishedAt).toISOString()),
)
const songs = await this.getSongs(releaseDateMap.keys())
songs.forEach((song) => (song.releaseDate = releaseDateMap.get(song.id)))
return songs
} }
/** /**
* @param ids An array of youtube video ids. * @param {Iterable<string>} ids An iterable of youtube video ids. Duplicate ids will be filtered out
* @throws Error if the fetch failed. TypeError if an invalid videoId was included in the request. * @returns {Promise<Song[]>} An array of Songs. Unavailable songs/videos will be filtered out.
*/ */
// ? So far don't know if there is a cap for how many you can request a once. My entire 247 song J-core playlist worked in one request no problem. public async getSongs(ids: Iterable<string>): Promise<Song[]> {
// ? The only thing this method is really missing is release dates, which would be the easiest thing to get from the v3 API, but I'm struggling to const uniqueIds = new Set(ids)
// ? justify making those requests just for the release date. Maybe I can justify it if I find other data in the v3 API that would be useful.
public async getSongs(ids: string[]): Promise<Song[]> {
if (ids.some((id) => !isValidVideoId(id))) throw TypeError('Invalid video id in request')
const response = await this.requestManager const response = await this.api.v1.WEB_REMIX('music/get_queue', { json: { videoIds: Array.from(uniqueIds) } }).json<InnerTube.Queue.Response>()
.innerTubeFetch('/queue', { body: { videoIds: ids } })
.then((response) => response.json() as Promise<InnerTube.Queue.Response | InnerTube.Queue.ErrorResponse>)
.catch(() => null)
if (!response) throw Error(`Failed to fetch ${ids.length} songs from connection ${this.id}`) const items = response.queueDatas
.map((item) => {
// If song has both an ATV 'counterpart' and video, this will chose whichever matches the id provided in the request
if ('playlistPanelVideoRenderer' in item.content) return item.content.playlistPanelVideoRenderer
if ('error' in response) { const primaryRenderer = item.content.playlistPanelVideoWrapperRenderer.primaryRenderer.playlistPanelVideoRenderer
if (response.error.status === 'NOT_FOUND') throw TypeError('Invalid video id in request') if (uniqueIds.has(primaryRenderer.videoId)) return primaryRenderer
const errorMessage = `Unknown playlist items response error: ${response.error.message}` return item.content.playlistPanelVideoWrapperRenderer.counterpart[0].counterpartRenderer.playlistPanelVideoRenderer
console.error(errorMessage, response.error.status, response.error.code) })
throw Error(errorMessage) .filter((item) => 'title' in item) // TODO: Add indication that some results were filtered out
}
return response.queueDatas.map((item) => { return items.map((item) => {
// ? When the song has both a video and auto-generated version, currently I have it set to choose the 'counterpart' auto-generated version as they usually have more complete data,
// ? as well as the benefit of scalable thumbnails. However, In the event the video versions actually do provide something of value, maybe scrape both.
const itemData =
'playlistPanelVideoRenderer' in item.content ? item.content.playlistPanelVideoRenderer : item.content.playlistPanelVideoWrapperRenderer.counterpart[0].counterpartRenderer.playlistPanelVideoRenderer
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection'] const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const id = itemData.videoId const id = item.videoId
const name = itemData.title.runs[0].text const name = item.title.runs[0].text
const duration = timestampToSeconds(itemData.lengthText.runs[0].text) const duration = timestampToSeconds(item.lengthText.runs[0].text)
const thumbnailUrl = extractLargestThumbnailUrl(itemData.thumbnail.thumbnails) const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.thumbnails)
const artists: Song['artists'] = [] const artists: Song['artists'] = []
let album: Song['album'] let album: Song['album']
let uploader: Song['uploader'] let uploader: Song['uploader']
itemData.longBylineText.runs.forEach((run) => { item.longBylineText.runs.forEach((run) => {
if (!run.navigationEndpoint) return if (!run.navigationEndpoint) return
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
@@ -475,31 +502,79 @@ export class YouTubeMusic implements Connection {
} }
}) })
const isVideo = itemData.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV' const isVideo = item.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
return { connection, id, name, type: 'song', duration, thumbnailUrl, artists: artists.length > 0 ? artists : undefined, album, uploader, isVideo } satisfies Song return { connection, id, name, type: 'song', duration, thumbnailUrl, artists: artists.length > 0 ? artists : undefined, album, uploader, isVideo } satisfies Song
}) })
} }
} }
class YTRequestManager { class APIManager {
private readonly connectionId: string private readonly connectionId: string
private currentAccessToken: string private currentAccessToken: string
private readonly refreshToken: string private readonly refreshToken: string
private expiry: number private expiry: number
public readonly v1: {
WEB_REMIX: KyInstance
ANDROID_TESTSUITE: KyInstance
}
public readonly v3: KyInstance
constructor(connectionId: string, accessToken: string, refreshToken: string, expiry: number) { constructor(connectionId: string, accessToken: string, refreshToken: string, expiry: number) {
this.connectionId = connectionId this.connectionId = connectionId
this.currentAccessToken = accessToken this.currentAccessToken = accessToken
this.refreshToken = refreshToken this.refreshToken = refreshToken
this.expiry = expiry this.expiry = expiry
const authHook = async (request: Request) => request.headers.set('authorization', `Bearer ${await this.accessToken}`)
const baseV1 = ky.create({
prefixUrl: 'https://music.youtube.com/youtubei/v1/',
method: 'post',
hooks: { beforeRequest: [authHook] },
})
const WEB_REMIX = baseV1.extend({
json: {
context: {
client: {
clientName: 'WEB_REMIX',
get clientVersion() {
const currentDate = new Date()
const year = currentDate.getUTCFullYear().toString()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0')
return `1.${year + month + day}.01.00`
},
},
},
},
})
const ANDROID_TESTSUITE = baseV1.extend({
json: {
context: {
client: {
clientName: 'ANDROID_TESTSUITE',
clientVersion: '1.9',
},
},
},
})
this.v1 = { WEB_REMIX, ANDROID_TESTSUITE }
this.v3 = ky.create({
prefixUrl: 'https://www.googleapis.com/youtube/v3/',
hooks: { beforeRequest: [authHook] },
})
} }
private accessTokenRefreshRequest: Promise<string> | null = null private accessTokenRefreshRequest: Promise<string> | null = null
public get accessToken() { private get accessToken() {
const refreshAccessToken = async () => { const refreshAccessToken = async () => {
const MAX_TRIES = 3
let tries = 0
const refreshDetails = { const refreshDetails = {
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
client_secret: YOUTUBE_API_CLIENT_SECRET, client_secret: YOUTUBE_API_CLIENT_SECRET,
@@ -507,23 +582,14 @@ class YTRequestManager {
grant_type: 'refresh_token', grant_type: 'refresh_token',
} }
while (tries < MAX_TRIES) { const { access_token, expires_in } = await ky.post('https://oauth2.googleapis.com/token', { json: refreshDetails, retry: 3 }).json<{ access_token: string; expires_in: number }>()
++tries
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: JSON.stringify(refreshDetails),
}).catch(() => null)
if (!response || !response.ok) continue
const { access_token, expires_in } = await response.json()
const expiry = Date.now() + expires_in * 1000 const expiry = Date.now() + expires_in * 1000
return { accessToken: access_token as string, expiry } return { accessToken: access_token, expiry }
} }
throw Error(`Failed to refresh access tokens for YouTube Music connection: ${this.connectionId}`) // ? Maybe build in a buffer to prevent a token expiring while a request is in flight
} if (this.expiry >= Date.now()) return new Promise<string>((resolve) => resolve(this.currentAccessToken))
if (this.expiry > Date.now()) return new Promise<string>((resolve) => resolve(this.currentAccessToken))
if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
@@ -542,54 +608,27 @@ class YTRequestManager {
return this.accessTokenRefreshRequest return this.accessTokenRefreshRequest
} }
public async innerTubeFetch(relativeRefrence: string, options?: { body?: Record<string, unknown> }) {
const url = new URL(relativeRefrence, 'https://music.youtube.com/youtubei/v1/')
const headers = new Headers({
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
authorization: `Bearer ${await this.accessToken}`,
})
const currentDate = new Date()
const year = currentDate.getUTCFullYear().toString()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0')
const context = {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${year + month + day}.01.00`,
},
} }
const body = Object.assign({ context }, options?.body) class LibaryManager {
return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) })
}
}
class YTLibaryManager {
private readonly connectionId: string private readonly connectionId: string
private readonly requestManager: YTRequestManager private readonly api: APIManager
private readonly youtubeUserId: string private readonly youtubeUserId: string
constructor(connectionId: string, youtubeUserId: string, requestManager: YTRequestManager) { constructor(connectionId: string, youtubeUserId: string, apiManager: APIManager) {
this.connectionId = connectionId this.connectionId = connectionId
this.requestManager = requestManager this.api = apiManager
this.youtubeUserId = youtubeUserId this.youtubeUserId = youtubeUserId
} }
public async albums(): Promise<Album[]> { public async albums(): Promise<Album[]> {
const albumData = await this.requestManager.innerTubeFetch('/browse', { body: { browseId: 'FEmusic_liked_albums' } }).then((response) => response.json() as Promise<InnerTube.Library.AlbumResponse>) const albumData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_albums' } }).json<InnerTube.Library.AlbumResponse>()
const { items, continuations } = albumData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer const { items, continuations } = albumData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
let continuation = continuations?.[0].nextContinuationData.continuation let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) { while (continuation) {
const continuationData = await this.requestManager const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.AlbumContinuationResponse>()
.innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`)
.then((response) => response.json() as Promise<InnerTube.Library.AlbumContinuationResponse>)
items.push(...continuationData.continuationContents.gridContinuation.items) items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
@@ -614,17 +653,13 @@ class YTLibaryManager {
} }
public async artists(): Promise<Artist[]> { public async artists(): Promise<Artist[]> {
const artistsData = await this.requestManager const artistsData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_library_corpus_track_artists' } }).json<InnerTube.Library.ArtistResponse>()
.innerTubeFetch('/browse', { body: { browseId: 'FEmusic_library_corpus_track_artists' } })
.then((response) => response.json() as Promise<InnerTube.Library.ArtistResponse>)
const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer
let continuation = continuations?.[0].nextContinuationData.continuation let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) { while (continuation) {
const continuationData = await this.requestManager const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.ArtistContinuationResponse>()
.innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`)
.then((response) => response.json() as Promise<InnerTube.Library.ArtistContinuationResponse>)
contents.push(...continuationData.continuationContents.musicShelfContinuation.contents) contents.push(...continuationData.continuationContents.musicShelfContinuation.contents)
continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
@@ -641,15 +676,13 @@ class YTLibaryManager {
} }
public async playlists(): Promise<Playlist[]> { public async playlists(): Promise<Playlist[]> {
const playlistData = await this.requestManager.innerTubeFetch('/browse', { body: { browseId: 'FEmusic_liked_playlists' } }).then((response) => response.json() as Promise<InnerTube.Library.PlaylistResponse>) const playlistData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_playlists' } }).json<InnerTube.Library.PlaylistResponse>()
const { items, continuations } = playlistData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer const { items, continuations } = playlistData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
let continuation = continuations?.[0].nextContinuationData.continuation let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) { while (continuation) {
const continuationData = await this.requestManager const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.PlaylistContinuationResponse>()
.innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`)
.then((response) => response.json() as Promise<InnerTube.Library.PlaylistContinuationResponse>)
items.push(...continuationData.continuationContents.gridContinuation.items) items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- ***** BEGIN LICENSE BLOCK *****
- Part of the Jellyfin project (https://jellyfin.media)
-
- All copyright belongs to the Jellyfin contributors; a full list can
- be found in the file CONTRIBUTORS.md
-
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
- ***** END LICENSE BLOCK ***** -->
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
<defs>
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
<stop offset="0" style="stop-color:#AA5CC3"/>
<stop offset="1" style="stop-color:#00A4DC"/>
</linearGradient>
</defs>
<title>icon-transparent</title>
<g id="icon-transparent">
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)"/>
<path id="outer-shape" d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z" fill="url(#linear-gradient)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
]>
<svg version="1.1" id="Layer_1" xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 176 176"
enable-background="new 0 0 176 176" xml:space="preserve">
<metadata>
<sfw xmlns="&ns_sfw;">
<slices></slices>
<sliceSourceBounds bottomLeftOrigin="true" height="176" width="176" x="8" y="-184"></sliceSourceBounds>
</sfw>
</metadata>
<g id="XMLID_167_">
<circle id="XMLID_791_" fill="#FF0000" cx="88" cy="88" r="88"/>
<path id="XMLID_42_" fill="#FFFFFF" d="M88,46c23.1,0,42,18.8,42,42s-18.8,42-42,42s-42-18.8-42-42S64.9,46,88,46 M88,42
c-25.4,0-46,20.6-46,46s20.6,46,46,46s46-20.6,46-46S113.4,42,88,42L88,42z"/>
<polygon id="XMLID_274_" fill="#FFFFFF" points="72,111 111,87 72,65 "/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -46,6 +46,12 @@ class Queue {
return this.currentSongs[this.currentPosition] return this.currentSongs[this.currentPosition]
} }
get upNext() {
if (this.currentSongs.length === 0 && this.currentPosition >= this.currentSongs.length) return null
return this.currentSongs[this.currentPosition + 1]
}
get list() { get list() {
return this.currentSongs return this.currentSongs
} }

View File

@@ -85,4 +85,7 @@
#sidebar { #sidebar {
grid-area: 2 / 1 / 3 / 2; grid-area: 2 / 1 / 3 / 2;
} }
#content-wrapper {
grid-area: 2 / 2 / 3 / 4;
}
</style> </style>

View File

@@ -2,9 +2,9 @@
import LazyImage from '$lib/components/media/lazyImage.svelte' import LazyImage from '$lib/components/media/lazyImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import ArtistList from '$lib/components/media/artistList.svelte' import ArtistList from '$lib/components/media/artistList.svelte'
import Services from '$lib/services.json'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores' import { queue, newestAlert } from '$lib/stores'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
export let album: Album export let album: Album
@@ -33,7 +33,9 @@
<i slot="icon" class="fa-solid fa-play text-2xl" /> <i slot="icon" class="fa-solid fa-play text-2xl" />
</IconButton> </IconButton>
</div> </div>
<img id="connection-type-icon" class="absolute left-2 top-2 h-9 w-9 opacity-0 transition-opacity" src={Services[album.connection.type].icon} alt={Services[album.connection.type].displayName} /> <div id="connection-type-icon" class="absolute left-2 top-2 h-9 w-9 opacity-0 transition-opacity">
<ServiceLogo type={album.connection.type} />
</div>
</div> </div>
<div class="py-2 text-center text-sm"> <div class="py-2 text-center text-sm">
<div class="line-clamp-2">{album.name}</div> <div class="line-clamp-2">{album.name}</div>

View File

@@ -4,9 +4,9 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const query = url.searchParams.get('query') const query = url.searchParams.get('query')
if (query) { if (query) {
const getSearchResults = async () => const getSearchResults = async () =>
fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {}) fetch(`/api/v1/search?query=${query}&userId=${locals.user.id}&types=song,album,artist,playlist`)
.then((response) => response.json() as Promise<{ searchResults: (Song | Album | Artist | Playlist)[] }>) .then((response) => response.json() as Promise<{ results: (Song | Album | Artist | Playlist)[] }>)
.then((data) => data.searchResults) .then((data) => data.results)
return { searchResults: getSearchResults() } return { searchResults: getSearchResults() }
} }

View File

@@ -3,29 +3,31 @@ import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import type { PageServerLoad, Actions } from './$types' import type { PageServerLoad, Actions } from './$types'
import { DB } from '$lib/server/db' import { DB } from '$lib/server/db'
import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin' import { Jellyfin } from '$lib/server/jellyfin'
import { google } from 'googleapis' import { google } from 'googleapis'
import ky from 'ky'
export const load: PageServerLoad = async ({ fetch, locals }) => { export const load: PageServerLoad = async ({ fetch, locals, url }) => {
const getConnectionInfo = async () => const getConnectionInfo = () =>
fetch(`/api/users/${locals.user.id}/connections`) ky
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>) .get(`api/users/${locals.user.id}/connections`, { fetch, prefixUrl: url.origin })
.then((data) => data.connections) .json<{ connections: ConnectionInfo[] }>()
.then((response) => response.connections)
.catch(() => ({ error: 'Failed to retrieve connections' })) .catch(() => ({ error: 'Failed to retrieve connections' }))
return { connections: getConnectionInfo() } return { connections: getConnectionInfo() }
} }
export const actions: Actions = { export const actions: Actions = {
authenticateJellyfin: async ({ request, fetch, locals }) => { authenticateJellyfin: async ({ request, fetch, locals, url }) => {
const formData = await request.formData() const formData = await request.formData()
const { serverUrl, username, password, deviceId } = Object.fromEntries(formData) const { serverUrl, username, password, deviceId } = Object.fromEntries(formData)
if (!URL.canParse(serverUrl.toString())) return fail(400, { message: 'Invalid Server URL' }) if (!URL.canParse(serverUrl.toString())) return fail(400, { message: 'Invalid Server URL' })
const authData = await Jellyfin.authenticateByName(username.toString(), password.toString(), new URL(serverUrl.toString()), deviceId.toString()).catch((error: JellyfinFetchError) => error) const authData = await Jellyfin.authenticateByName(username.toString(), password.toString(), new URL(serverUrl.toString()), deviceId.toString()).catch(() => null)
if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message }) if (!authData) return fail(400, { message: 'Failed to Authenticate' })
const userId = locals.user.id const userId = locals.user.id
const serviceUserId = authData.User.Id const serviceUserId = authData.User.Id
@@ -33,13 +35,12 @@ export const actions: Actions = {
const newConnectionId = await DB.connections.insert({ id: DB.uuid(), userId, type: 'jellyfin', serviceUserId, serverUrl: serverUrl.toString(), accessToken }, 'id').then((data) => data[0].id) const newConnectionId = await DB.connections.insert({ id: DB.uuid(), userId, type: 'jellyfin', serviceUserId, serverUrl: serverUrl.toString(), accessToken }, 'id').then((data) => data[0].id)
const newConnection = await fetch(`/api/connections?id=${newConnectionId}`) const connectionsResponse = await ky.get(`api/connections?id=${newConnectionId}`, { fetch, prefixUrl: url.origin }).json<{ connections: ConnectionInfo[] }>()
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>) const newConnection = connectionsResponse.connections[0]
.then((data) => data.connections[0])
return { newConnection } return { newConnection }
}, },
youtubeMusicLogin: async ({ request, fetch, locals }) => { youtubeMusicLogin: async ({ request, fetch, locals, url }) => {
const formData = await request.formData() const formData = await request.formData()
const { code } = Object.fromEntries(formData) const { code } = Object.fromEntries(formData)
const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // ! DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD. const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // ! DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD.
@@ -56,9 +57,8 @@ export const actions: Actions = {
.insert({ id: DB.uuid(), userId, type: 'youtube-music', serviceUserId, accessToken: access_token!, refreshToken: refresh_token!, expiry: expiry_date! }, 'id') .insert({ id: DB.uuid(), userId, type: 'youtube-music', serviceUserId, accessToken: access_token!, refreshToken: refresh_token!, expiry: expiry_date! }, 'id')
.then((data) => data[0].id) .then((data) => data[0].id)
const newConnection = await fetch(`/api/connections?id=${newConnectionId}`) const connectionsResponse = await ky.get(`api/connections?id=${newConnectionId}`, { fetch, prefixUrl: url.origin }).json<{ connections: ConnectionInfo[] }>()
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>) const newConnection = connectionsResponse.connections[0]
.then((data) => data.connections[0])
return { newConnection } return { newConnection }
}, },

View File

@@ -3,8 +3,6 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import type { LayoutData } from '../$types' import type { LayoutData } from '../$types'
import Services from '$lib/services.json' import Services from '$lib/services.json'
import JellyfinIcon from '$lib/static/jellyfin-icon.svg'
import YouTubeMusicIcon from '$lib/static/youtube-music-icon.svg'
import JellyfinAuthBox from './jellyfinAuthBox.svelte' import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/stores.js' import { newestAlert } from '$lib/stores.js'
import type { PageServerData } from './$types.js' import type { PageServerData } from './$types.js'
@@ -15,6 +13,7 @@
import { enhance } from '$app/forms' import { enhance } from '$app/forms'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import Loader from '$lib/components/util/loader.svelte' import Loader from '$lib/components/util/loader.svelte'
import ServiceLogo from '$lib/components/util/serviceLogo.svelte'
export let data: PageServerData & LayoutData export let data: PageServerData & LayoutData
let connections: ConnectionInfo[] let connections: ConnectionInfo[]
@@ -129,11 +128,15 @@
<h1 class="py-2 text-xl">Add Connection</h1> <h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4"> <div class="flex flex-wrap gap-2 pb-4">
<button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}> <button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
<img src={JellyfinIcon} alt="Jellyfin icon" class="aspect-square h-full p-2" /> <div class="aspect-square h-full p-2">
<ServiceLogo type={'jellyfin'} />
</div>
</button> </button>
<form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}> <form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}>
<button class="add-connection-button h-14 rounded-md"> <button class="add-connection-button h-14 rounded-md">
<img src={YouTubeMusicIcon} alt="YouTube Music icon" class="aspect-square h-full p-2" /> <div class="aspect-square h-full p-2">
<ServiceLogo type={'youtube-music'} />
</div>
</button> </button>
</form> </form>
</div> </div>

View File

@@ -1,11 +1,11 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildConnection } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',') const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
if (!ids) return new Response('Missing id query parameter', { status: 400 }) if (!ids) return new Response('Missing id query parameter', { status: 400 })
const connections = (await Promise.all(ids.map((id) => buildConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null) const connections = (await Promise.all(ids.map((id) => ConnectionFactory.getConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null)
const getConnectionInfo = (connection: Connection) => const getConnectionInfo = (connection: Connection) =>
connection.getConnectionInfo().catch((reason) => { connection.getConnectionInfo().catch((reason) => {

View File

@@ -1,9 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildConnection } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url }) => {
const connectionId = params.connectionId! const connectionId = params.connectionId!
const connection = await buildConnection(connectionId).catch(() => null) const connection = await ConnectionFactory.getConnection(connectionId).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const albumId = url.searchParams.get('id') const albumId = url.searchParams.get('id')

View File

@@ -1,10 +1,10 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildConnection } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const { connectionId, albumId } = params const { connectionId, albumId } = params
const connection = await buildConnection(connectionId!).catch(() => null) const connection = await ConnectionFactory.getConnection(connectionId!).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const items = await connection.getAlbumItems(albumId!).catch(() => null) const items = await connection.getAlbumItems(albumId!).catch(() => null)

View File

@@ -1,9 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildConnection } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url }) => {
const connectionId = params.connectionId! const connectionId = params.connectionId!
const connection = await buildConnection(connectionId).catch(() => null) const connection = await ConnectionFactory.getConnection(connectionId).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const playlistId = url.searchParams.get('id') const playlistId = url.searchParams.get('id')

View File

@@ -1,9 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildConnection } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url }) => {
const { connectionId, playlistId } = params const { connectionId, playlistId } = params
const connection = await buildConnection(connectionId!).catch(() => null) const connection = await ConnectionFactory.getConnection(connectionId!).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const startIndexString = url.searchParams.get('startIndex') const startIndexString = url.searchParams.get('startIndex')

View File

@@ -1,23 +0,0 @@
import type { RequestHandler } from '@sveltejs/kit'
import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url }) => {
const { query, userId, filter } = Object.fromEntries(url.searchParams) as { [k: string]: string | undefined }
if (!(query && userId)) return new Response('Missing search parameter', { status: 400 })
const userConnections = await buildUserConnections(userId).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 })
let checkedFilter: 'song' | 'album' | 'artist' | 'playlist' | undefined
if (filter === 'song' || filter === 'album' || filter === 'artist' || filter === 'playlist') checkedFilter = filter
const search = (connection: Connection) =>
connection.search(query, checkedFilter).catch((reason) => {
console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)
return null
})
const searchResults = (await Promise.all(userConnections.map(search))).flat().filter((result): result is Song | Album | Artist | Playlist => result !== null)
return Response.json({ searchResults })
}

View File

@@ -1,8 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildUserConnections } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userConnections = await buildUserConnections(params.userId!).catch(() => null) const userConnections = await ConnectionFactory.getUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const getConnectionInfo = (connection: Connection) => const getConnectionInfo = (connection: Connection) =>

View File

@@ -1,8 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildUserConnections } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userConnections = await buildUserConnections(params.userId!).catch(() => null) const userConnections = await ConnectionFactory.getUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const items = (await Promise.all(userConnections.map((connection) => connection.library.albums()))).flat() const items = (await Promise.all(userConnections.map((connection) => connection.library.albums()))).flat()

View File

@@ -1,8 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildUserConnections } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userConnections = await buildUserConnections(params.userId!).catch(() => null) const userConnections = await ConnectionFactory.getUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const items = (await Promise.all(userConnections.map((connection) => connection.library.artists()))).flat() const items = (await Promise.all(userConnections.map((connection) => connection.library.artists()))).flat()

View File

@@ -1,8 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildUserConnections } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userConnections = await buildUserConnections(params.userId!).catch(() => null) const userConnections = await ConnectionFactory.getUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const items = (await Promise.all(userConnections.map((connection) => connection.library.playlists()))).flat() const items = (await Promise.all(userConnections.map((connection) => connection.library.playlists()))).flat()

View File

@@ -1,10 +1,10 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildUserConnections } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
// This is temporary functionally for the sake of developing the app. // This is temporary functionally for the sake of developing the app.
// In the future will implement more robust algorithm for offering recommendations // In the future will implement more robust algorithm for offering recommendations
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userConnections = await buildUserConnections(params.userId!).catch(() => null) const userConnections = await ConnectionFactory.getUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const getRecommendations = (connection: Connection) => const getRecommendations = (connection: Connection) =>

View File

@@ -1,12 +1,12 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { buildConnection } from '$lib/server/api-helper' import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url, request }) => { 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 // Might want to re-evaluate how specific I make these ^ v error response messages
const connection = await buildConnection(connectionId).catch(() => null) const connection = await ConnectionFactory.getConnection(connectionId).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' }) const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' })

View File

@@ -0,0 +1,35 @@
import type { RequestHandler } from '@sveltejs/kit'
import { ConnectionFactory } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('query')
const userId = url.searchParams.get('userId')
const typeSet = new Set<'song' | 'album' | 'artist' | 'playlist'>()
url.searchParams
.get('types')
?.toLowerCase()
.split(',')
.forEach((type) => {
type = type.trim()
if (type === 'song' || type === 'album' || type === 'artist' || type === 'playlist') {
typeSet.add(type)
}
})
if (!(query && userId && typeSet.size > 0)) return new Response('Bad Request', { status: 400 })
const userConnections = await ConnectionFactory.getUserConnections(userId).catch(() => null)
if (!userConnections) return new Response('Bad Request', { status: 400 })
const search = (connection: Connection) =>
connection.search(query, typeSet).catch((reason) => {
console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)
return null
})
const results = await Promise.all(userConnections.map(search)).then((results) => results.flat().filter((result) => result !== null))
return Response.json({ results })
}