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

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fade, slide } from 'svelte/transition'
import { fade, slide, fly } from 'svelte/transition'
import { queue } from '$lib/stores'
import Services from '$lib/services.json'
// import { FastAverageColor } from 'fast-average-color'
import Slider from '$lib/components/util/slider.svelte'
import Loader from '$lib/components/util/loader.svelte'
@@ -9,6 +10,7 @@
import IconButton from '$lib/components/util/iconButton.svelte'
import ScrollingText from '$lib/components/util/scrollingText.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.
// 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
const minutes = Math.floor(seconds / 60)
seconds = seconds - minutes * 60
const durationString = `${minutes}:${seconds.toString().padStart(2, '0')}`
return hours > 0 ? `${hours}:`.concat(durationString) : durationString
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
}
$: updateMediaSession(currentlyPlaying)
@@ -103,10 +104,15 @@
</script>
{#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}
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10 pr-8">
<section class="flex w-80 gap-3">
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10">
<section class="flex w-96 min-w-64 gap-3">
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} />
</div>
@@ -140,7 +146,7 @@
<IconButton on:click={() => $queue.next()}>
<i slot="icon" class="fa-solid fa-forward-step text-xl" />
</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" />
<Slider
bind:this={progressBar}
@@ -157,8 +163,8 @@
<span bind:this={durationTimestamp} class="w-16 text-left" />
</div>
</section>
<section class="flex items-center justify-end gap-2.5 py-6 text-lg">
<div id="volume-slider" class="mx-4 flex h-10 w-44 items-center gap-3">
<section class="flex items-center justify-end gap-2.5 py-6 pr-8 text-lg">
<div class="mx-4 flex h-10 w-40 items-center gap-3">
<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'}" />
</IconButton>
@@ -183,40 +189,41 @@
</main>
{:else}
<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'} />
</div>
<section id="song-queue-wrapper" class="h-full px-24 py-20">
<section class="relative">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} />
</section>
<section class="no-scrollbar flex max-h-full flex-col gap-3 overflow-y-scroll">
<strong class="ml-2 text-2xl">UP NEXT</strong>
{#each $queue.list as item}
{@const isCurrent = item === currentlyPlaying}
<button
on:click={() => {
if (!isCurrent) $queue.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'} />
<section class="relative grid h-full grid-rows-[1fr_4fr] gap-4 px-24 py-16">
<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>
<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>
{/if}
</section>
</div>
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'contain'} objectPosition={'left'} />
</section>
<section class="px-8">
<div id="progress-bar-expanded" class="mb-6">
<span bind:this={expandedCurrentTimeTimestamp} class="text-right" />
<section class="self-center px-16">
<div class="mb-7 flex min-w-56 flex-grow items-center justify-items-center gap-3 font-light">
<span bind:this={expandedCurrentTimeTimestamp} />
<Slider
bind:this={expandedProgressBar}
max={duration}
@@ -229,74 +236,71 @@
seeking = false
}}
/>
<span bind:this={expandedDurationTimestamp} class="text-left" />
<span bind:this={expandedDurationTimestamp} />
</div>
<div id="expanded-controls">
<div class="flex flex-col gap-1.5 overflow-hidden">
<div class="h-9">
<div class="flex min-w-56 flex-col gap-1.5 overflow-hidden">
<div class="h-10">
<ScrollingText>
<strong slot="text" class="text-3xl">{currentlyPlaying.name}</strong>
<strong slot="text" class="text-4xl">{currentlyPlaying.name}</strong>
</ScrollingText>
</div>
{#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" />
<div class="flex gap-3 text-lg font-medium text-neutral-300">
{#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader}
<ArtistList mediaItem={currentlyPlaying} />
</div>
{/if}
{#if currentlyPlaying.album}
<div class="flex flex-nowrap items-center font-extralight">
<i class="fa-solid fa-compact-disc mr-3 text-sm" />
{/if}
{#if currentlyPlaying.album}
<strong>&bullet;</strong>
<a
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
>
</div>
{/if}
</div>
<div class="flex h-min w-full items-center justify-center gap-2 text-2xl">
<button on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())} class="aspect-square h-16">
<i class="fa-solid {shuffled ? 'fa-shuffle' : 'fa-right-left'}" />
</button>
<button class="aspect-square h-16" on:click={() => $queue.previous()}>
<i class="fa-solid fa-backward-step" />
</button>
<button on:click={() => (paused = !paused)} class="relative grid aspect-square h-16 place-items-center rounded-full bg-white text-black">
{#if waiting}
<Loader size={2.5} />
{:else}
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
{/if}
</button>
<button class="aspect-square h-16" on:click={() => $queue.next()}>
<i class="fa-solid fa-forward-step" />
</button>
<button on:click={() => (loop = !loop)} class="aspect-square h-16">
<i class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</button>
</div>
</div>
<div class="flex h-16 w-full items-center justify-center gap-2 text-2xl">
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
<IconButton on:click={() => $queue.previous()}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="relative aspect-square h-full rounded-full bg-white text-black">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</div>
<IconButton on:click={() => $queue.clear()}>
<i slot="icon" class="fa-solid fa-stop" />
</IconButton>
<IconButton on:click={() => $queue.next()}>
<i slot="icon" class="fa-solid fa-forward-step" />
</IconButton>
<IconButton on:click={() => (loop = !loop)}>
<i slot="icon" class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</IconButton>
</div>
<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">
<button on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))} class="aspect-square h-8">
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
</button>
<div id="slider-wrapper" class="w-24 transition-all duration-500">
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
<div class="mx-4 flex h-10 w-40 items-center gap-3">
<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'}" />
</IconButton>
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
<button class="aspect-square h-8" on:click={() => (expanded = false)}>
<i class="fa-solid fa-compress" />
</button>
<button class="aspect-square h-8" on:click={() => $queue.clear()}>
<i class="fa-solid fa-xmark" />
</button>
<IconButton on:click={() => (expanded = false)}>
<i slot="icon" class="fa-solid fa-chevron-down" />
</IconButton>
</section>
</div>
</section>
@@ -314,7 +318,7 @@
on:waiting={() => (waiting = true)}
on:ended={() => $queue.next()}
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}
/>
</div>
@@ -326,26 +330,12 @@
}
#expanded-player {
display: grid;
grid-template-rows: calc(100% - 11rem) 11rem;
}
#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;
grid-template-rows: 4fr 1fr;
}
#expanded-controls {
display: grid;
gap: 1rem;
gap: 3rem;
align-items: center;
grid-template-columns: 1fr min-content 1fr !important;
}
</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;
opacity: 0;
}
#slider-track:hover > #slider-trail {
background-color: var(--slider-color);
}
#slider-track:hover > #slider-trail,
#slider-track:focus > #slider-trail {
background-color: var(--slider-color);
}
#slider-track:hover > #slider-thumb {
opacity: 1;
}
#slider-track:hover > #slider-thumb,
#slider-track:focus > #slider-thumb {
opacity: 1;
}
#slider-track:not(:hover):not(:focus) > #slider-trail {
transition: right 50ms linear;
}
</style>

View File

@@ -10,39 +10,43 @@ export async function mixExists(mixId: string): Promise<Boolean> {
return Boolean(await DB.mixes.where('id', mixId).first(DB.db.raw('EXISTS(SELECT 1)')))
}
function connectionBuilder(schema: Schemas.Connections): Connection {
const { id, userId, type, serviceUserId, accessToken } = schema
switch (type) {
case 'jellyfin':
return new Jellyfin(id, userId, serviceUserId, schema.serverUrl, accessToken)
case 'youtube-music':
return new YouTubeMusic(id, userId, serviceUserId, accessToken, schema.refreshToken, schema.expiry)
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
switch (type) {
case 'jellyfin':
return new Jellyfin(id, userId, serviceUserId, schema.serverUrl, accessToken)
case 'youtube-music':
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 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'
@@ -6,88 +8,89 @@ export class Jellyfin implements Connection {
public readonly id: string
private readonly userId: string
private readonly jellyfinUserId: string
private readonly serverUrl: string
private readonly services: JellyfinServices
private readonly parsers: JellyfinParsers
private libraryManager?: JellyfinLibraryManager
private readonly api: KyInstance
constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
this.id = id
this.userId = userId
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() {
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
}
// * This method can NOT throw an error
public async getConnectionInfo() {
const userEndpoint = `/Users/${this.jellyfinUserId}`
const systemEndpoint = '/System/Info'
const getUserData = () =>
this.services
.request(userEndpoint)
.then((response) => response.json() as Promise<JellyfinAPI.UserResponse>)
this.api(`Users/${this.jellyfinUserId}`)
.json<JellyfinAPI.UserResponse>()
.catch(() => null)
const getSystemData = () =>
this.services
.request(systemEndpoint)
.then((response) => response.json() as Promise<JellyfinAPI.SystemResponse>)
this.api('System/Info')
.json<JellyfinAPI.SystemResponse>()
.catch(() => null)
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 {
id: this.id,
userId: this.userId,
type: 'jellyfin',
serverUrl: this.services.serverUrl().toString(),
serverUrl: this.serverUrl,
serverName: systemData?.ServerName,
jellyfinUserId: this.jellyfinUserId,
username: userData?.Name,
} satisfies ConnectionInfo
}
public async search(searchTerm: string, filter: 'song'): Promise<Song[]>
public async search(searchTerm: string, filter: 'album'): Promise<Album[]>
public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]>
public async search(searchTerm: string, filter: 'playlist'): Promise<Playlist[]>
public async search(searchTerm: string, filter?: undefined): Promise<(Song | Album | Artist | Playlist)[]>
public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> {
public async search<T extends keyof MediaItemTypeMap>(searchTerm: string, types: Set<T>): Promise<MediaItemTypeMap[T][]> {
const filterMap = { song: 'Audio', album: 'MusicAlbum', artist: 'MusicArtist', playlist: 'Playlist' } as const
const searchParams = new URLSearchParams({
searchTerm,
includeItemTypes: filter ? filterMap[filter] : Object.values(filterMap).join(','),
includeItemTypes: Array.from(types, (type) => filterMap[type]).join(','),
recursive: 'true',
})
const searchResults = await this.services
.request(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.then((response) => response.json() as Promise<{ Items: (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[] }>)
const searchResults = await this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`).json<{ Items: (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[] }>()
return searchResults.Items.map((result) => {
switch (result.Type) {
case 'Audio':
return this.services.parseSong(result)
return this.parsers.parseSong(result)
case 'MusicAlbum':
return this.services.parseAlbum(result)
return this.parsers.parseAlbum(result)
case 'MusicArtist':
return this.services.parseArtist(result)
return this.parsers.parseArtist(result)
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)[]> {
const searchParams = new URLSearchParams({
SortBy: 'PlayCount',
@@ -97,10 +100,9 @@ export class Jellyfin implements Connection {
limit: '10',
})
return this.services
.request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>)
.then((data) => data.Items.map((song) => this.services.parseSong(song)))
const mostPlayedResponse = await this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`).json<{ Items: JellyfinAPI.Song[] }>()
return mostPlayedResponse.Items.map(this.parsers.parseSong)
}
// 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,
})
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) {
return this.services
.request(`/Users/${this.jellyfinUserId}/Items/${id}`)
.then((response) => response.json() as Promise<JellyfinAPI.Album>)
.then(this.services.parseAlbum)
return this.api(`Users/${this.jellyfinUserId}/Items/${id}`).json<JellyfinAPI.Album>().then(this.parsers.parseAlbum)
}
public async getAlbumItems(id: string) {
@@ -130,17 +129,13 @@ export class Jellyfin implements Connection {
sortBy: 'ParentIndexNumber,IndexNumber,SortName',
})
return this.services
.request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>)
.then((data) => data.Items.map(this.services.parseSong))
return this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.json<{ Items: JellyfinAPI.Song[] }>()
.then((response) => response.Items.map(this.parsers.parseSong))
}
public async getPlaylist(id: string) {
return this.services
.request(`/Users/${this.jellyfinUserId}/Items/${id}`)
.then((response) => response.json() as Promise<JellyfinAPI.Playlist>)
.then(this.services.parsePlaylist)
return this.api(`Users/${this.jellyfinUserId}/Items/${id}`).json<JellyfinAPI.Playlist>().then(this.parsers.parsePlaylist)
}
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?.limit) searchParams.append('limit', options.limit.toString())
return this.services
.request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>)
.then((data) => data.Items.map(this.services.parseSong))
return this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
.json<{ Items: JellyfinAPI.Song[] }>()
.then((response) => response.Items.map(this.parsers.parseSong))
}
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 fetch(authUrl, {
method: 'POST',
body: JSON.stringify({
Username: username,
Pw: password,
}),
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="${PUBLIC_VERSION}"`,
},
})
.catch(() => {
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>
return ky
.post(new URL('Users/AuthenticateByName', serverUrl.origin), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="${PUBLIC_VERSION}"`,
},
json: {
Username: username,
Pw: password,
},
})
.json<JellyfinAPI.AuthenticationResponse>()
}
}
class JellyfinServices {
class JellyfinParsers {
private readonly connectionId: string
private readonly serverUrl: string
public readonly serverUrl: (endpoint?: string) => URL
public readonly request: (endpoint: string, options?: RequestInit) => Promise<Response>
constructor(connectionId: string, serverUrl: string, accessToken: string) {
constructor(connectionId: string, serverUrl: string) {
this.connectionId = connectionId
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
})
}
this.serverUrl = serverUrl
}
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 {
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 => ({
@@ -255,122 +226,31 @@ class JellyfinServices {
class JellyfinLibraryManager {
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.services = services
this.api = api
this.parsers = parsers
}
public async albums(): Promise<Album[]> {
return this.services
.request(`/Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=MusicAlbum&recursive=true`)
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Album[] }>)
.then((data) => data.Items.map(this.services.parseAlbum))
return this.api(`Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=MusicAlbum&recursive=true`)
.json<{ Items: JellyfinAPI.Album[] }>()
.then((response) => response.Items.map(this.parsers.parseAlbum))
}
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
return this.services
.request('/Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true')
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Artist[] }>)
.then((data) => data.Items.map(this.services.parseArtist))
return this.api('Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true')
.json<{ Items: JellyfinAPI.Artist[] }>()
.then((response) => response.Items.map(this.parsers.parseArtist))
}
public async playlists(): Promise<Playlist[]> {
return this.services
.request(`/Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=Playlist&recursive=true`)
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Playlist[] }>)
.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
return this.api(`Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=Playlist&recursive=true`)
.json<{ Items: JellyfinAPI.Playlist[] }>()
.then((response) => response.Items.map(this.parsers.parsePlaylist))
}
}

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
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 {
interface AlbumResponse {
contents: {
@@ -379,14 +417,6 @@ export namespace InnerTube {
}
}
interface ErrorResponse {
error: {
code: number
message: string
status: string
}
}
type MusicResponsiveHeaderRenderer = {
thumbnail: {
musicThumbnailRenderer: {
@@ -473,7 +503,8 @@ export namespace InnerTube {
runs: [
{
text: string // Song Name
navigationEndpoint: {
navigationEndpoint?: {
// This will be missing if the song is not playable
watchEndpoint: {
videoId: string
watchEndpointMusicSupportedConfigs: {
@@ -660,14 +691,6 @@ export namespace InnerTube {
}
}
interface ErrorResponse {
error: {
code: number
message: string
status: string
}
}
type MusicResponsiveListItemRenderer = {
flexColumns: [
{
@@ -746,7 +769,7 @@ export namespace InnerTube {
interface PlayerErrorResponse {
playabilityStatus: {
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<{
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: {
// This occurs when the playlist has a video or auto-generated counterpart
primaryRenderer: {
playlistPanelVideoRenderer: PlaylistPanelVideoRenderer
playlistPanelVideoRenderer: PlaylistPanelVideoRenderer | BlockedPlaylistPanelVideoRenderer
}
counterpart: [
{
counterpartRenderer: {
playlistPanelVideoRenderer: PlaylistPanelVideoRenderer
playlistPanelVideoRenderer: PlaylistPanelVideoRenderer | BlockedPlaylistPanelVideoRenderer
}
},
]
@@ -786,14 +809,6 @@ export namespace InnerTube {
}>
}
interface ErrorResponse {
error: {
code: number
message: string
status: string
}
}
type PlaylistPanelVideoRenderer = {
title: {
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
interface SearchResponse {
contents: unknown
namespace Search {
interface Response {
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
@@ -854,3 +1647,60 @@ export namespace InnerTube {
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 { 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 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 {
public readonly id: string
private readonly userId: string
private readonly youtubeUserId: string
private readonly requestManager: YTRequestManager
private libraryManager?: YTLibaryManager
private readonly api: APIManager
private libraryManager?: LibaryManager
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
this.id = id
this.userId = userId
this.youtubeUserId = youtubeUserId
this.requestManager = new YTRequestManager(id, accessToken, refreshToken, expiry)
this.api = new APIManager(id, accessToken, refreshToken, expiry)
}
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
}
// * This method can NOT throw an error
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
if (access_token) {
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
}
const username = response?.header.musicVisualHeaderRenderer.title.runs[0].text
const profilePicture = response ? extractLargestThumbnailUrl(response.header.musicVisualHeaderRenderer.foregroundThumbnail.musicThumbnailRenderer.thumbnail.thumbnails) : undefined
return {
id: this.id,
@@ -50,29 +46,265 @@ export class YouTubeMusic implements Connection {
} satisfies ConnectionInfo
}
// ! Need to completely rework this method - Currently returns empty array
public async search(searchTerm: string, filter: 'song'): Promise<Song[]>
public async search(searchTerm: string, filter: 'album'): Promise<Album[]>
public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]>
public async search(searchTerm: string, filter: 'playlist'): Promise<Playlist[]>
public async search(searchTerm: string, filter?: undefined): Promise<(Song | Album | Artist | Playlist)[]>
public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> {
public async search<T extends keyof MediaItemTypeMap>(searchTerm: string, types: Set<T>): Promise<MediaItemTypeMap[T][]> {
const searchFilterParams = {
song: 'EgWKAQIIAWoMEA4QChADEAQQCRAF',
video: 'EgWKAQIQAWoMEA4QChADEAQQCRAF',
album: 'EgWKAQIYAWoMEA4QChADEAQQCRAF',
artist: 'EgWKAQIgAWoMEA4QChADEAQQCRAF',
playlist: 'Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D',
playlist: 'EgeKAQQoAEABagwQDhAKEAMQBBAJEAU%3D',
} 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
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) {
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).
// * Go support him and go support Ukraine (he's Ukrainian)
const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', {
headers: {
// 'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', <-- 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 playerResponse = await this.api.v1.ANDROID_TESTSUITE('player', { json: { videoId: id } }).json<InnerTube.Player.PlayerResponse>()
const formats = playerResponse.streamingData.formats?.concat(playerResponse.streamingData.adaptiveFormats ?? [])
const audioOnlyFormats = formats?.filter(
@@ -142,20 +346,7 @@ export class YouTubeMusic implements Connection {
* @param id The browseId of the album
*/
public async getAlbum(id: string): Promise<Album> {
const albumResponse = await this.requestManager
.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 albumResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: id } }).json<InnerTube.Album.AlbumResponse>()
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
*/
public async getAlbumItems(id: string): Promise<Song[]> {
const albumResponse = await this.requestManager
.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 albumResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: id } }).json<InnerTube.Album.AlbumResponse>()
const contents = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicShelfRenderer.contents
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) {
const continuationResponse = await this.requestManager
.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}`)
const continuationResponse = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Album.ContinuationResponse>()
contents.push(...continuationResponse.continuationContents.musicShelfRenderer.contents)
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 playableItems = contents.filter((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined)
const playableIds = contents.map((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId)
const dividedItems = []
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 }
})
return this.getSongs(playableIds)
}
/**
* @param id The id of the playlist (not the browseId!).
*/
public async getPlaylist(id: string): Promise<Playlist> {
const playlistResponse = await this.requestManager
.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 playlistResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'VL'.concat(id) } }).json<InnerTube.Playlist.Response>()
const header =
'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
*/
public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }): Promise<Song[]> {
const startIndex = options?.startIndex,
limit = options?.limit
const startIndex = options?.startIndex ?? 0,
limit = options?.limit ?? Infinity
const playlistResponse = await this.requestManager
.innerTubeFetch('/browse', { body: { browseId: 'VL'.concat(id) } })
.then((response) => response.json() as Promise<InnerTube.Playlist.Response | InnerTube.Playlist.ErrorResponse>)
.catch(() => null)
const playlistItemSearchParams = new URLSearchParams({
playlistId: id,
maxResults: '50',
part: 'snippet,contentDetails,status',
})
if (!playlistResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`)
const playableItems: YouTubeDataApi.PlaylistItems.Item<'snippet' | 'contentDetails' | 'status'>[] = []
while (playableItems.length < startIndex + limit) {
const itemsResponse = await this.api.v3(`playlistItems?${playlistItemSearchParams.toString()}`).json<YouTubeDataApi.PlaylistItems.Response<'snippet' | 'contentDetails' | 'status'>>()
if ('error' in playlistResponse) {
if (playlistResponse.error.status === 'NOT_FOUND' || playlistResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube playlist id')
playableItems.push(...itemsResponse.items.filter((item) => item.status.privacyStatus === 'public' || item.snippet.videoOwnerChannelId === item.snippet.channelId))
const errorMessage = `Unknown playlist items response error: ${playlistResponse.error.message}`
console.error(errorMessage)
throw Error(errorMessage)
if (!itemsResponse.nextPageToken) break // Reached the end of the playlist, retrieved all items
playlistItemSearchParams.set('pageToken', itemsResponse.nextPageToken)
}
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,
const slicedItems = playableItems.slice(startIndex, startIndex + limit) // Removes over-fetch
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()),
)
let continuation = playlistResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation
const songs = await this.getSongs(releaseDateMap.keys())
songs.forEach((song) => (song.releaseDate = releaseDateMap.get(song.id)))
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 = []
for (let i = 0; i < scrapedItems.length; i += 50) dividedItems.push(scrapedItems.slice(i, i + 50))
const access_token = await this.requestManager.accessToken
const videoSchemaMap = new Map<string, youtube_v3.Schema$Video>()
const videoSchemas = (await Promise.all(dividedItems.map((chunk) => ytDataApi.videos.list({ part: ['snippet'], id: chunk.map((item) => item.id), access_token })))).map((response) => response.data.items!).flat()
videoSchemas.forEach((schema) => videoSchemaMap.set(schema.id!, schema))
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
return scrapedItems.map((item) => {
const correspondingSchema = videoSchemaMap.get(item.id)!
const { id, name, duration, album, isVideo } = item
const existingThumbnail = item.thumbnailUrl
const artists = item.artists?.map((artist) => ({ id: artist.id ?? correspondingSchema.snippet?.channelId!, name: artist.name }))
const uploader = item.uploader ? { id: item.uploader?.id ?? correspondingSchema.snippet?.channelId!, name: item.uploader.name } : undefined
const videoThumbnails = correspondingSchema.snippet?.thumbnails!
const thumbnailUrl = existingThumbnail ?? videoThumbnails.maxres?.url ?? videoThumbnails.standard?.url ?? videoThumbnails.high?.url ?? videoThumbnails.medium?.url ?? videoThumbnails.default?.url!
const releaseDate = new Date(correspondingSchema.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? correspondingSchema.snippet?.publishedAt!).toISOString()
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, uploader, isVideo } satisfies Song
})
return songs
}
/**
* @param ids An array of youtube video ids.
* @throws Error if the fetch failed. TypeError if an invalid videoId was included in the request.
* @param {Iterable<string>} ids An iterable of youtube video ids. Duplicate ids will be filtered out
* @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.
// ? 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
// ? 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')
public async getSongs(ids: Iterable<string>): Promise<Song[]> {
const uniqueIds = new Set(ids)
const response = await this.requestManager
.innerTubeFetch('/queue', { body: { videoIds: ids } })
.then((response) => response.json() as Promise<InnerTube.Queue.Response | InnerTube.Queue.ErrorResponse>)
.catch(() => null)
const response = await this.api.v1.WEB_REMIX('music/get_queue', { json: { videoIds: Array.from(uniqueIds) } }).json<InnerTube.Queue.Response>()
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) {
if (response.error.status === 'NOT_FOUND') throw TypeError('Invalid video id in request')
const primaryRenderer = item.content.playlistPanelVideoWrapperRenderer.primaryRenderer.playlistPanelVideoRenderer
if (uniqueIds.has(primaryRenderer.videoId)) return primaryRenderer
const errorMessage = `Unknown playlist items response error: ${response.error.message}`
console.error(errorMessage, response.error.status, response.error.code)
throw Error(errorMessage)
}
return item.content.playlistPanelVideoWrapperRenderer.counterpart[0].counterpartRenderer.playlistPanelVideoRenderer
})
.filter((item) => 'title' in item) // TODO: Add indication that some results were filtered out
return response.queueDatas.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
return items.map((item) => {
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const id = itemData.videoId
const name = itemData.title.runs[0].text
const duration = timestampToSeconds(itemData.lengthText.runs[0].text)
const thumbnailUrl = extractLargestThumbnailUrl(itemData.thumbnail.thumbnails)
const id = item.videoId
const name = item.title.runs[0].text
const duration = timestampToSeconds(item.lengthText.runs[0].text)
const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.thumbnails)
const artists: Song['artists'] = []
let album: Song['album']
let uploader: Song['uploader']
itemData.longBylineText.runs.forEach((run) => {
item.longBylineText.runs.forEach((run) => {
if (!run.navigationEndpoint) return
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
})
}
}
class YTRequestManager {
class APIManager {
private readonly connectionId: string
private currentAccessToken: string
private readonly refreshToken: string
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) {
this.connectionId = connectionId
this.currentAccessToken = accessToken
this.refreshToken = refreshToken
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
public get accessToken() {
private get accessToken() {
const refreshAccessToken = async () => {
const MAX_TRIES = 3
let tries = 0
const refreshDetails = {
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
client_secret: YOUTUBE_API_CLIENT_SECRET,
@@ -507,23 +582,14 @@ class YTRequestManager {
grant_type: 'refresh_token',
}
while (tries < MAX_TRIES) {
++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 ky.post('https://oauth2.googleapis.com/token', { json: refreshDetails, retry: 3 }).json<{ access_token: string; expires_in: number }>()
const { access_token, expires_in } = await response.json()
const expiry = Date.now() + expires_in * 1000
return { accessToken: access_token as string, expiry }
}
throw Error(`Failed to refresh access tokens for YouTube Music connection: ${this.connectionId}`)
const expiry = Date.now() + expires_in * 1000
return { accessToken: access_token, expiry }
}
if (this.expiry > Date.now()) return new Promise<string>((resolve) => resolve(this.currentAccessToken))
// ? 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.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
@@ -542,54 +608,27 @@ class YTRequestManager {
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)
return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) })
}
}
class YTLibaryManager {
class LibaryManager {
private readonly connectionId: string
private readonly requestManager: YTRequestManager
private readonly api: APIManager
private readonly youtubeUserId: string
constructor(connectionId: string, youtubeUserId: string, requestManager: YTRequestManager) {
constructor(connectionId: string, youtubeUserId: string, apiManager: APIManager) {
this.connectionId = connectionId
this.requestManager = requestManager
this.api = apiManager
this.youtubeUserId = youtubeUserId
}
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
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.requestManager
.innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`)
.then((response) => response.json() as Promise<InnerTube.Library.AlbumContinuationResponse>)
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.AlbumContinuationResponse>()
items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
@@ -614,17 +653,13 @@ class YTLibaryManager {
}
public async artists(): Promise<Artist[]> {
const artistsData = await this.requestManager
.innerTubeFetch('/browse', { body: { browseId: 'FEmusic_library_corpus_track_artists' } })
.then((response) => response.json() as Promise<InnerTube.Library.ArtistResponse>)
const artistsData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_library_corpus_track_artists' } }).json<InnerTube.Library.ArtistResponse>()
const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.requestManager
.innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`)
.then((response) => response.json() as Promise<InnerTube.Library.ArtistContinuationResponse>)
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.ArtistContinuationResponse>()
contents.push(...continuationData.continuationContents.musicShelfContinuation.contents)
continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
@@ -641,15 +676,13 @@ class YTLibaryManager {
}
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
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.requestManager
.innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`)
.then((response) => response.json() as Promise<InnerTube.Library.PlaylistContinuationResponse>)
const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json<InnerTube.Library.PlaylistContinuationResponse>()
items.push(...continuationData.continuationContents.gridContinuation.items)
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]
}
get upNext() {
if (this.currentSongs.length === 0 && this.currentPosition >= this.currentSongs.length) return null
return this.currentSongs[this.currentPosition + 1]
}
get list() {
return this.currentSongs
}