Added library mixin to YTMusic and Jellyfin

This commit is contained in:
Eclypsed
2024-06-04 22:37:19 -04:00
parent 292dc1425e
commit cb4cc1d949
24 changed files with 1017 additions and 1109 deletions

View File

@@ -15,7 +15,7 @@
}).then((response) => response.json() as Promise<{ items: Song[] }>)
const items = itemsResponse.items
queueRef.setQueue(...items)
queueRef.setQueue({ songs: items })
}
</script>
@@ -41,7 +41,7 @@
on:click={() => {
switch (mediaItem.type) {
case 'song':
queueRef.current = mediaItem
queueRef.setQueue({ songs: [mediaItem] })
break
case 'album':
case 'playlist':

View File

@@ -11,30 +11,27 @@
let expanded = false
let paused = true,
shuffle = false,
repeat = false
loop = false
let volume: number,
muted = false
$: shuffled = $queue.isShuffled
const maxVolume = 0.5
let volume: number
let waiting: boolean
$: muted ? (volume = 0) : (volume = Number(localStorage.getItem('volume')))
$: if (volume && !muted) localStorage.setItem('volume', volume.toString())
const formatTime = (seconds: number): string => {
seconds = Math.floor(seconds)
function formatTime(seconds: number) {
seconds = Math.round(seconds)
const hours = Math.floor(seconds / 3600)
seconds = seconds - hours * 3600
const minutes = Math.floor(seconds / 60)
seconds = seconds - minutes * 60
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
const durationString = `${minutes}:${seconds.toString().padStart(2, '0')}`
return hours > 0 ? `${hours}:`.concat(durationString) : durationString
}
$: if (currentlyPlaying) updateMediaSession(currentlyPlaying)
const updateMediaSession = (media: Song) => {
function updateMediaSession(media: Song) {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: media.name,
@@ -51,7 +48,7 @@
volume = Number(storedVolume)
} else {
localStorage.setItem('volume', (maxVolume / 2).toString())
volume = 0.5
volume = maxVolume / 2
}
if ('mediaSession' in navigator) {
@@ -103,13 +100,13 @@
</div>
<section class="flex flex-col justify-center gap-1">
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') ?? currentlyPlaying.uploader?.name}</div>
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div>
</section>
</section>
<section class="flex min-w-max flex-col items-center justify-center gap-1">
<div class="flex items-center gap-3 text-lg">
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-8">
<i class="fa-solid fa-shuffle" />
<button on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())} class="aspect-square h-8">
<i class="fa-solid {shuffled ? 'fa-shuffle' : 'fa-right-left'}" />
</button>
<button class="aspect-square h-8" on:click={() => $queue.previous()}>
<i class="fa-solid fa-backward-step" />
@@ -124,8 +121,8 @@
<button class="aspect-square h-8" on:click={() => $queue.next()}>
<i class="fa-solid fa-forward-step" />
</button>
<button on:click={() => (repeat = !repeat)} class="aspect-square h-8">
<i class="fa-solid fa-repeat" />
<button on:click={() => (loop = !loop)} class="aspect-square h-8">
<i class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</button>
</div>
<div class="flex items-center justify-items-center gap-2">
@@ -149,11 +146,17 @@
</section>
<section class="flex items-center justify-end gap-2 pr-2 text-lg">
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
<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} />
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
</div>
<button class="aspect-square h-8" on:click={() => (expanded = true)}>
@@ -188,7 +191,7 @@
<div class="h-20 w-20 bg-cover bg-center" style="background-image: url('/api/remoteImage?url={item.thumbnailUrl}');" />
<div class="justify-items-left text-left">
<div class="line-clamp-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 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>
@@ -223,7 +226,7 @@
>
</div>
<div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;">
{#if 'artists' in currentlyPlaying && currentlyPlaying.artists && currentlyPlaying.artists.length > 0}
{#if currentlyPlaying.artists && currentlyPlaying.artists.length > 0}
{#each currentlyPlaying.artists as artist, index}
<a
on:click={() => (expanded = false)}
@@ -234,7 +237,7 @@
<span class="mr-1 text-lg">,</span>
{/if}
{/each}
{:else if 'uploader' in currentlyPlaying && currentlyPlaying.uploader}
{:else if currentlyPlaying.uploader}
<a
on:click={() => (expanded = false)}
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
@@ -252,8 +255,8 @@
</div>
</div>
<div class="flex w-full items-center justify-center gap-2 text-2xl">
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-16">
<i class="fa-solid fa-shuffle" />
<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" />
@@ -268,17 +271,23 @@
<button class="aspect-square h-16" on:click={() => $queue.next()}>
<i class="fa-solid fa-forward-step" />
</button>
<button on:click={() => (repeat = !repeat)} class="aspect-square h-16">
<i class="fa-solid fa-repeat" />
<button on:click={() => (loop = !loop)} class="aspect-square h-16">
<i class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
</button>
</div>
<section class="flex items-center justify-end gap-2 text-xl">
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
<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} />
<Slider
bind:value={volume}
max={maxVolume}
on:seeked={() => {
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
</div>
</div>
<button class="aspect-square h-8" on:click={() => (expanded = false)}>
@@ -305,6 +314,7 @@
on:ended={() => $queue.next()}
on:error={() => setTimeout(() => audioElement.load(), 5000)}
src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
{loop}
/>
</div>
{/if}

View File

@@ -6,45 +6,50 @@ export class Jellyfin implements Connection {
public readonly id: string
private readonly userId: string
private readonly jellyfinUserId: string
private readonly serverUrl: string
private readonly accessToken: string
private readonly authHeader: Headers
private readonly services: JellyfinServices
private libraryManager?: JellyfinLibraryManager
constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
this.id = id
this.userId = userId
this.jellyfinUserId = jellyfinUserId
this.serverUrl = serverUrl
this.accessToken = accessToken
this.authHeader = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` })
this.services = new JellyfinServices(this.id, serverUrl, accessToken)
}
public get library() {
if (!this.libraryManager) this.libraryManager = new JellyfinLibraryManager(this.jellyfinUserId, this.services)
return this.libraryManager
}
public async getConnectionInfo() {
const userUrl = new URL(`Users/${this.jellyfinUserId}`, this.serverUrl)
const systemUrl = new URL('System/Info', this.serverUrl)
const userEndpoint = `Users/${this.jellyfinUserId}`
const systemEndpoint = 'System/Info'
const getUserData = () =>
fetch(userUrl, { headers: this.authHeader })
this.services
.request(userEndpoint)
.then((response) => response.json() as Promise<JellyfinAPI.UserResponse>)
.catch(() => null)
const getSystemData = () =>
fetch(systemUrl, { headers: this.authHeader })
this.services
.request(systemEndpoint)
.then((response) => response.json() as Promise<JellyfinAPI.SystemResponse>)
.catch(() => null)
const [userData, systemData] = await Promise.all([getUserData(), getSystemData()])
if (!userData) console.error(`Fetch to ${userUrl.toString()} failed`)
if (!systemData) console.error(`Fetch to ${systemUrl.toString()} failed`)
if (!userData) console.error(`Fetch to ${userEndpoint.toString()} failed`)
if (!systemData) console.error(`Fetch to ${systemEndpoint.toString()} failed`)
return {
id: this.id,
userId: this.userId,
type: 'jellyfin',
serverUrl: this.serverUrl,
serverUrl: this.services.serverUrl().toString(),
serverName: systemData?.ServerName,
jellyfinUserId: this.jellyfinUserId,
username: userData?.Name,
@@ -65,21 +70,20 @@ export class Jellyfin implements Connection {
recursive: 'true',
})
const searchURL = new URL(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl)
const searchResponse = await fetch(searchURL, { headers: this.authHeader })
if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL.toString())
const searchResults = (await searchResponse.json()).Items as (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[]
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)[] }>)
return searchResults.map((result) => {
return searchResults.Items.map((result) => {
switch (result.Type) {
case 'Audio':
return this.parseSong(result)
return this.services.parseSong(result)
case 'MusicAlbum':
return this.parseAlbum(result)
return this.services.parseAlbum(result)
case 'MusicArtist':
return this.parseArtist(result)
return this.services.parseArtist(result)
case 'Playlist':
return this.parsePlaylist(result)
return this.services.parsePlaylist(result)
}
})
}
@@ -93,11 +97,10 @@ export class Jellyfin implements Connection {
limit: '10',
})
const mostPlayedSongsURL = new URL(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl)
const mostPlayed: { Items: JellyfinAPI.Song[] } = await fetch(mostPlayedSongsURL, { headers: this.authHeader }).then((response) => response.json())
return mostPlayed.Items.map((song) => this.parseSong(song))
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)))
}
// TODO: Figure out why seeking a jellyfin song takes so much longer than ytmusic (hls?)
@@ -111,27 +114,14 @@ export class Jellyfin implements Connection {
userId: this.jellyfinUserId,
})
const audioUrl = new URL(`Audio/${id}/universal?${audoSearchParams.toString()}`, this.serverUrl)
return fetch(audioUrl, { headers: Object.assign(headers, this.authHeader) })
return this.services.request(`Audio/${id}/universal?${audoSearchParams.toString()}`, { headers, keepalive: true })
}
public async getAlbum(id: string) {
const albumUrl = new URL(`/Users/${this.jellyfinUserId}/Items/${id}`, this.serverUrl)
const album = await fetch(albumUrl, { headers: this.authHeader })
.then((response) => {
if (!response.ok) {
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`)
throw TypeError(`Invalid album ${id} of jellyfin connection ${this.id}`)
}
return response.json() as Promise<JellyfinAPI.Album>
})
.catch(() => null)
if (!album) throw Error(`Failed to fetch album ${id} of jellyfin connection ${this.id}`)
return this.parseAlbum(album)
return this.services
.request(`/Users/${this.jellyfinUserId}/Items/${id}`)
.then((response) => response.json() as Promise<JellyfinAPI.Album>)
.then(this.services.parseAlbum)
}
public async getAlbumItems(id: string) {
@@ -140,133 +130,35 @@ export class Jellyfin implements Connection {
sortBy: 'ParentIndexNumber,IndexNumber,SortName',
})
const albumItemsUrl = new URL(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl)
const albumItems = await fetch(albumItemsUrl, { headers: this.authHeader })
.then((response) => {
if (!response.ok) {
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`)
throw TypeError(`Invalid album ${id} of jellyfin connection ${this.id}`)
}
return response.json() as Promise<{ Items: JellyfinAPI.Song[] }>
})
.catch(() => null)
if (!albumItems) throw Error(`Failed to fetch album ${id} items of jellyfin connection ${this.id}`)
return albumItems.Items.map((item) => this.parseSong(item))
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))
}
public async getPlaylist(id: string) {
const playlistUrl = new URL(`/Users/${this.jellyfinUserId}/Items/${id}`, this.serverUrl)
const playlist = await fetch(playlistUrl, { headers: this.authHeader })
.then((response) => {
if (!response.ok) {
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`)
throw TypeError(`Invalid playlist ${id} of jellyfin connection ${this.id}`)
}
return response.json() as Promise<JellyfinAPI.Playlist>
})
.catch(() => null)
if (!playlist) throw Error(`Failed to fetch playlist ${id} of jellyfin connection ${this.id}`)
return this.parsePlaylist(playlist)
return this.services
.request(`/Users/${this.jellyfinUserId}/Items/${id}`)
.then((response) => response.json() as Promise<JellyfinAPI.Playlist>)
.then(this.services.parsePlaylist)
}
public async getPlaylistItems(id: string, startIndex?: number, limit?: number) {
public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }) {
const searchParams = new URLSearchParams({
parentId: id,
includeItemTypes: 'Audio',
})
if (startIndex) searchParams.append('startIndex', startIndex.toString())
if (limit) searchParams.append('limit', limit.toString())
if (options?.startIndex) searchParams.append('startIndex', options.startIndex.toString())
if (options?.limit) searchParams.append('limit', options.limit.toString())
const playlistItemsUrl = new URL(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl)
const playlistItems = await fetch(playlistItemsUrl, { headers: this.authHeader })
.then((response) => {
if (!response.ok) {
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`)
throw TypeError(`Invalid playlist ${id} of jellyfin connection ${this.id}`)
}
return response.json() as Promise<{ Items: JellyfinAPI.Song[] }>
})
.catch(() => null)
if (!playlistItems) throw Error(`Failed to fetch playlist ${id} items of jellyfin connection ${this.id}`)
return playlistItems.Items.map((item) => this.parseSong(item))
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))
}
private parseSong = (song: JellyfinAPI.Song): Song => {
const thumbnailUrl = song.ImageTags?.Primary
? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).toString()
: song.AlbumPrimaryImageTag
? new URL(`Items/${song.AlbumId}/Images/Primary`, this.serverUrl).toString()
: jellyfinLogo
const artists: Song['artists'] = song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name }))
const album: Song['album'] = song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined
return {
connection: { id: this.id, type: 'jellyfin' },
id: song.Id,
name: song.Name,
type: 'song',
duration: Math.floor(song.RunTimeTicks / 10000000),
thumbnailUrl,
releaseDate: song.ProductionYear ? new Date(song.ProductionYear.toString()).toISOString() : undefined,
artists,
album,
isVideo: false,
}
}
private parseAlbum = (album: JellyfinAPI.Album): Album => {
const thumbnailUrl = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, this.serverUrl).toString() : jellyfinLogo
const artists: Album['artists'] = album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists'
return {
connection: { id: this.id, type: 'jellyfin' },
id: album.Id,
name: album.Name,
type: 'album',
thumbnailUrl,
artists,
releaseYear: album.ProductionYear?.toString(),
}
}
private parseArtist(artist: JellyfinAPI.Artist): Artist {
const profilePicture = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, this.serverUrl).toString() : jellyfinLogo
return {
connection: { id: this.id, type: 'jellyfin' },
id: artist.Id,
name: artist.Name,
type: 'artist',
profilePicture,
}
}
private parsePlaylist = (playlist: JellyfinAPI.Playlist): Playlist => {
const thumbnailUrl = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, this.serverUrl).toString() : jellyfinLogo
return {
connection: { id: this.id, type: 'jellyfin' },
id: playlist.Id,
name: playlist.Name,
type: 'playlist',
thumbnailUrl,
}
}
public static authenticateByName = async (username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthenticationResponse> => {
public static async authenticateByName(username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthenticationResponse> {
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString()
return fetch(authUrl, {
method: 'POST',
@@ -289,6 +181,107 @@ export class Jellyfin implements Connection {
}
}
class JellyfinServices {
private readonly connectionId: string
public readonly serverUrl: (endpoint?: string) => URL
public readonly request: (endpoint: string, options?: RequestInit) => Promise<Response>
constructor(connectionId: string, serverUrl: string, accessToken: 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
})
}
}
private getBestThumbnail = (item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist) => {
const imageItemId = item.ImageTags?.Primary ? item.Id : 'AlbumPrimaryImageTag' in item && item.AlbumPrimaryImageTag ? item.AlbumId : undefined
return imageItemId ? this.serverUrl(`Items/${imageItemId}/Images/Primary`).toString() : jellyfinLogo
}
public parseSong = (song: JellyfinAPI.Song): Song => ({
connection: { id: this.connectionId, type: 'jellyfin' },
id: song.Id,
name: song.Name,
type: 'song',
duration: Math.floor(song.RunTimeTicks / 10000000),
thumbnailUrl: this.getBestThumbnail(song),
releaseDate: song.PremiereDate ? new Date(song.PremiereDate).toISOString() : undefined,
artists: song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name })),
album: song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined,
isVideo: false,
})
public parseAlbum = (album: JellyfinAPI.Album): Album => ({
connection: { id: this.connectionId, type: 'jellyfin' },
id: album.Id,
name: album.Name,
type: 'album',
thumbnailUrl: this.getBestThumbnail(album),
artists: album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists',
releaseYear: album.ProductionYear?.toString(),
})
public parseArtist = (artist: JellyfinAPI.Artist): Artist => ({
connection: { id: this.connectionId, type: 'jellyfin' },
id: artist.Id,
name: artist.Name,
type: 'artist',
profilePicture: this.getBestThumbnail(artist),
})
public parsePlaylist = (playlist: JellyfinAPI.Playlist): Playlist => ({
connection: { id: this.connectionId, type: 'jellyfin' },
id: playlist.Id,
name: playlist.Name,
type: 'playlist',
thumbnailUrl: this.getBestThumbnail(playlist),
})
}
class JellyfinLibraryManager {
private readonly jellyfinUserId: string
private readonly services: JellyfinServices
constructor(jellyfinUserId: string, services: JellyfinServices) {
this.jellyfinUserId = jellyfinUserId
this.services = services
}
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))
}
public async artists(): Promise<Artist[]> {
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))
}
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
@@ -306,6 +299,7 @@ declare namespace JellyfinAPI {
Id: string
Type: 'Audio'
RunTimeTicks: number
PremiereDate?: string
ProductionYear?: number
ArtistItems?: {
Name: string
@@ -328,6 +322,7 @@ declare namespace JellyfinAPI {
Id: string
Type: 'MusicAlbum'
RunTimeTicks: number
PremiereDate?: string
ProductionYear?: number
ArtistItems?: {
Name: string

View File

@@ -2,7 +2,7 @@
// When scraping thumbnails from the YTMusic browse pages, there are two different types of images that can be returned,
// standard video thumbnais and auto-generated square thumbnails for propper releases. The auto-generated thumbanils we want to
// keep from the scrape because:
// a) They can be easily scaled with ytmusic's weird fake query parameters (Ex: https://baseUrl=h1000)
// a) They can be easily scaled with ytmusic's weird fake query parameters (Ex: https://baseUrl=s1000)
// b) When fetched from the youtube data api it returns the 16:9 filled thumbnails like you would see in the standard yt player, we want the squares
//
// However when the thumbnail is for a video, we want to ignore it because the highest quality thumbnail will rarely be used in the ytmusic webapp
@@ -64,12 +64,279 @@ export namespace InnerTube {
id: string
name: string
type: 'playlist'
thumbnailUrl: string
createdBy: {
id: string
name: string
}
}
namespace Library {
interface AlbumResponse {
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: [
{
gridRenderer: {
items: Array<{
musicTwoRowItemRenderer: AlbumMusicTwoRowItemRenderer
}>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
},
]
}
}
}
},
]
}
}
}
interface AlbumContinuationResponse {
continuationContents: {
gridContinuation: {
items: Array<{
musicTwoRowItemRenderer: AlbumMusicTwoRowItemRenderer
}>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
}
}
type AlbumMusicTwoRowItemRenderer = {
thumbnailRenderer: {
musicThumbnailRenderer: musicThumbnailRenderer
}
title: {
runs: [
{
text: string
navigationEndpoint: {
browseEndpoint: {
browseId: string
}
}
},
]
}
subtitle: {
runs: Array<{
// Run's containing navigationEndpoints will be the album's artists. If many artists worked on an album a run will contain the text 'Various Artists'.
// The first run will always be 'Album', the last will always be the release year
text: string
navigationEndpoint?: {
browseEndpoint: {
browseId: string
}
}
}>
}
navigationEndpoint: {
browseEndpoint: {
browseId: string
}
}
}
interface ArtistResponse {
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: [
{
musicShelfRenderer: {
contents: Array<{
musicResponsiveListItemRenderer: ArtistMusicResponsiveListItemRenderer
}>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
},
]
}
}
}
},
]
}
}
}
interface ArtistContinuationResponse {
continuationContents: {
musicShelfContinuation: {
contents: Array<{
musicResponsiveListItemRenderer: ArtistMusicResponsiveListItemRenderer
}>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
}
}
type ArtistMusicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: musicThumbnailRenderer
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
]
navigationEndpoint: {
browseEndpoint: {
browseId: string
}
}
}
interface PlaylistResponse {
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: [
{
gridRenderer: {
items: Array<{
musicTwoRowItemRenderer:
| NewPlaylistMusicTwoRowItemRenderer
| LikedMusicPlaylistMusicTwoRowItemRenderer
| EpisodesPlaylistMusicTwoRowItemRenderer
| PlaylistMusicTwoRowItemRenderer
}>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
},
]
}
}
}
},
]
}
}
}
interface PlaylistContinuationResponse {
continuationContents: {
gridContinuation: {
items: Array<{
musicTwoRowItemRenderer: NewPlaylistMusicTwoRowItemRenderer | LikedMusicPlaylistMusicTwoRowItemRenderer | EpisodesPlaylistMusicTwoRowItemRenderer | PlaylistMusicTwoRowItemRenderer
}>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
}
}
type NewPlaylistMusicTwoRowItemRenderer = {
navigationEndpoint: {
createPlaylistEndpoint: Object
}
}
type LikedMusicPlaylistMusicTwoRowItemRenderer = {
navigationEndpoint: {
browseEndpoint: {
browseId: 'VLLM'
}
}
}
type EpisodesPlaylistMusicTwoRowItemRenderer = {
navigationEndpoint: {
browseEndpoint: {
browseId: 'VLSE'
}
}
}
type PlaylistMusicTwoRowItemRenderer = {
thumbnailRenderer: {
musicThumbnailRenderer: musicThumbnailRenderer
}
title: {
runs: [
{
text: string
},
]
}
subtitle: {
runs: Array<{
text: string
navigationEndpoint?: {
// If present, this run is the creator of the playlist
browseEndpoint: {
browseId: string
}
}
}>
}
navigationEndpoint: {
browseEndpoint: {
browseId: string
}
}
}
}
namespace Playlist {
interface PlaylistResponse {
contents: {

View File

@@ -1,17 +1,10 @@
import { google, type youtube_v3 } from 'googleapis'
import { youtube, type youtube_v3 } from 'googleapis/build/src/apis/youtube'
import { DB } from './db'
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'
const ytDataApi = google.youtube('v3')
const searchFilterParams = {
song: 'EgWKAQIIAWoMEA4QChADEAQQCRAF',
album: 'EgWKAQIYAWoMEA4QChADEAQQCRAF',
artist: 'EgWKAQIgAWoMEA4QChADEAQQCRAF',
playlist: 'Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D',
} as const
const ytDataApi = youtube('v3')
type ytMusicv1ApiRequestParams =
| {
@@ -38,71 +31,31 @@ type ScrapedMediaItemMap<MediaItem> = MediaItem extends InnerTube.ScrapedSong
? Playlist
: never
// 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 ytUserId: string
private currentAccessToken: string
private readonly refreshToken: string
private expiry: number
private readonly youtubeUserId: string
private readonly requestManager: YTRequestManager
private libraryManager?: YTLibaryManager
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
this.id = id
this.userId = userId
this.ytUserId = youtubeUserId
this.currentAccessToken = accessToken
this.refreshToken = refreshToken
this.expiry = expiry
this.youtubeUserId = youtubeUserId
this.requestManager = new YTRequestManager(id, accessToken, refreshToken, expiry)
}
private accessTokenRefreshRequest: Promise<string> | null = null
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, refresh_token: this.refreshToken, grant_type: 'refresh_token' }
public get library() {
if (!this.libraryManager) this.libraryManager = new YTLibaryManager(this.id, this.youtubeUserId, this.requestManager)
while (tries < MAX_TRIES) {
++tries
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: JSON.stringify(refreshDetails),
}).catch((reason) => {
console.error(`Fetch to refresh endpoint failed: ${reason}`)
return null
})
if (!response || !response.ok) continue
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.id}`)
}
if (this.expiry > Date.now()) return new Promise<string>((resolve) => resolve(this.currentAccessToken))
if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
this.accessTokenRefreshRequest = refreshAccessToken()
.then(({ accessToken, expiry }) => {
DB.updateTokens(this.id, { accessToken, refreshToken: this.refreshToken, expiry })
this.currentAccessToken = accessToken
this.expiry = expiry
this.accessTokenRefreshRequest = null
return accessToken
})
.catch((error: Error) => {
this.accessTokenRefreshRequest = null
throw error
})
return this.accessTokenRefreshRequest
return this.libraryManager
}
public async getConnectionInfo() {
const access_token = await this.accessToken.catch(() => null)
const access_token = await this.requestManager.accessToken.catch(() => null)
let username: string | undefined, profilePicture: string | undefined
if (access_token) {
@@ -112,55 +65,7 @@ export class YouTubeMusic implements Connection {
profilePicture = userChannel?.snippet?.thumbnails?.default?.url ?? undefined
}
return { id: this.id, userId: this.userId, type: 'youtube-music', youtubeUserId: this.ytUserId, username, profilePicture } satisfies ConnectionInfo
}
private async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) {
const headers = new Headers({
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
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`,
},
}
let url: string
let body: Record<string, any>
switch (requestDetails.type) {
case 'browse':
url = 'https://music.youtube.com/youtubei/v1/browse'
body = {
browseId: requestDetails.browseId,
context,
}
break
case 'search':
url = 'https://music.youtube.com/youtubei/v1/search'
body = {
query: requestDetails.searchTerm,
filter: requestDetails.filter ? searchFilterParams[requestDetails.filter] : undefined,
context,
}
break
case 'continuation':
url = `https://music.youtube.com/youtubei/v1/browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`
body = {
context,
}
break
}
return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) })
return { id: this.id, userId: this.userId, type: 'youtube-music', youtubeUserId: this.youtubeUserId, username, profilePicture } satisfies ConnectionInfo
}
// TODO: Figure out why this still breaks sometimes (Figured out one cause: "Episodes" can appear as videos)
@@ -173,7 +78,7 @@ export class YouTubeMusic implements Connection {
// Figure out how to handle Library and Uploads
// Depending on how I want to handle the playlist & library sync feature
const searchResulsts = (await this.ytMusicv1ApiRequest({ type: 'search', searchTerm, filter }).then((response) => response.json())) as InnerTube.SearchResponse
const searchResulsts = (await this.requestManager.ytMusicv1ApiRequest({ type: 'search', searchTerm, filter }).then((response) => response.json())) as InnerTube.SearchResponse
const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
@@ -221,7 +126,7 @@ export class YouTubeMusic implements Connection {
// TODO: Figure out why this still breaks sometimes (Figured out one cause: "Episodes" can appear as videos)
public async getRecommendations() {
const homeResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse
const homeResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse
const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
@@ -263,7 +168,7 @@ export class YouTubeMusic implements Connection {
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.accessToken}`, // * Including the access token is what enables access to premium content
authorization: `Bearer ${await this.requestManager.accessToken}`, // * Including the access token is what enables access to premium content
},
method: 'POST',
body: JSON.stringify({
@@ -308,20 +213,20 @@ export class YouTubeMusic implements Connection {
const hqAudioFormat = audioOnlyFormats.reduce((previous, current) => (previous.bitrate > current.bitrate ? previous : current))
return fetch(hqAudioFormat.url, { headers })
return fetch(hqAudioFormat.url, { headers, keepalive: true })
}
/**
* @param id The browseId of the album
*/
public async getAlbum(id: string): Promise<Album> {
const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
const albumResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
const header = albumResponse.header.musicDetailHeaderRenderer
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
const name = header.title.runs[0].text,
thumbnailUrl = cleanThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails[0].url)
thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = []
for (const run of header.subtitle.runs) {
@@ -344,7 +249,7 @@ export class YouTubeMusic implements Connection {
* @param id The browseId of the album
*/
public async getAlbumItems(id: string): Promise<Song[]> {
const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
const albumResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
const header = albumResponse.header.musicDetailHeaderRenderer
@@ -352,7 +257,7 @@ export class YouTubeMusic implements Connection {
let continuation = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.continuations?.[0].nextContinuationData.continuation
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const thumbnailUrl = cleanThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails[0].url)
const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails)
const album: Song['album'] = { id, name: header.title.runs[0].text }
const albumArtists = header.subtitle.runs
@@ -360,7 +265,7 @@ export class YouTubeMusic implements Connection {
.map((run) => ({ id: run.navigationEndpoint!.browseEndpoint.browseId, name: run.text }))
while (continuation) {
const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Album.ContinuationResponse
const continuationResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Album.ContinuationResponse
contents.push(...continuationResponse.continuationContents.musicShelfContinuation.contents)
continuation = continuationResponse.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
@@ -372,7 +277,7 @@ export class YouTubeMusic implements Connection {
const dividedItems = []
for (let i = 0; i < playableItems.length; i += 50) dividedItems.push(playableItems.slice(i, i + 50))
const access_token = await this.accessToken
const access_token = await this.requestManager.accessToken
const videoSchemas = await Promise.all(
dividedItems.map((chunk) =>
ytDataApi.videos.list({
@@ -400,7 +305,11 @@ export class YouTubeMusic implements Connection {
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
const artists = col1.musicResponsiveListItemFlexColumnRenderer.text.runs?.map((run) => ({ id: run.navigationEndpoint?.browseEndpoint.browseId ?? videoChannelMap.get(id)!, name: run.text })) ?? albumArtists
const artists =
col1.musicResponsiveListItemFlexColumnRenderer.text.runs?.map((run) => ({
id: run.navigationEndpoint?.browseEndpoint.browseId ?? videoChannelMap.get(id)!,
name: run.text,
})) ?? albumArtists
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo }
})
@@ -410,7 +319,8 @@ export class YouTubeMusic implements Connection {
* @param id The id of the playlist (not the browseId!).
*/
public async getPlaylist(id: string): Promise<Playlist> {
const playlistResponse = await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })
const playlistResponse = await this.requestManager
.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })
.then((response) => response.json() as Promise<InnerTube.Playlist.PlaylistResponse | InnerTube.Playlist.PlaylistErrorResponse>)
.catch(() => null)
@@ -432,13 +342,11 @@ export class YouTubeMusic implements Connection {
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
const name = header.title.runs[0].text
const thumbnailUrl = cleanThumbnailUrl(
header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url, // This is because sometimes the thumbnails for playlists can be video thumbnails
)
const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails)
let createdBy: Playlist['createdBy']
header.subtitle.runs.forEach((run) => {
if (run.navigationEndpoint?.browseEndpoint.browseId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
if (run.navigationEndpoint && run.navigationEndpoint.browseEndpoint.browseId !== this.youtubeUserId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
@@ -449,8 +357,12 @@ export class YouTubeMusic implements Connection {
* @param startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results
* @param limit The maximum number of playlist items to return
*/
public async getPlaylistItems(id: string, startIndex?: number, limit?: number): Promise<Song[]> {
const playlistResponse = await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })
public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }): Promise<Song[]> {
const startIndex = options?.startIndex,
limit = options?.limit
const playlistResponse = await this.requestManager
.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })
.then((response) => response.json() as Promise<InnerTube.Playlist.PlaylistResponse | InnerTube.Playlist.PlaylistErrorResponse>)
.catch(() => null)
@@ -472,7 +384,7 @@ export class YouTubeMusic implements Connection {
playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation
while (continuation && (!limit || playableContents.length < (startIndex ?? 0) + limit)) {
const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Playlist.ContinuationResponse
const continuationResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Playlist.ContinuationResponse
const playableContinuationContents = continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents.filter(
(item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined,
)
@@ -490,7 +402,7 @@ export class YouTubeMusic implements Connection {
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const 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]
@@ -514,7 +426,7 @@ export class YouTubeMusic implements Connection {
const dividedItems = []
for (let i = 0; i < scrapedItems.length; i += 50) dividedItems.push(scrapedItems.slice(i, i + 50))
const access_token = await this.accessToken
const 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))
@@ -538,8 +450,7 @@ export class YouTubeMusic implements Connection {
private async scrapedToMediaItems<T extends (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[]>(scrapedItems: T): Promise<ScrapedMediaItemMap<T[number]>[]> {
const songIds = new Set<string>(),
albumIds = new Set<string>(),
playlistIds = new Set<string>()
albumIds = new Set<string>()
scrapedItems.forEach((item) => {
switch (item.type) {
@@ -547,12 +458,6 @@ export class YouTubeMusic implements Connection {
songIds.add(item.id)
if (item.album?.id && !item.album.name) albumIds.add(item.album.id) // This is here because sometimes it is not possible to get the album name directly from a page, only the id
break
case 'album':
albumIds.add(item.id)
break
case 'playlist':
playlistIds.add(item.id)
break
}
})
@@ -560,7 +465,7 @@ export class YouTubeMusic implements Connection {
const dividedIds: string[][] = []
for (let i = 0; i < songIdArray.length; i += 50) dividedIds.push(songIdArray.slice(i, i + 50))
const access_token = await this.accessToken
const access_token = await this.requestManager.accessToken
const getSongDetails = () =>
Promise.all(dividedIds.map((idsChunk) => ytDataApi.videos.list({ part: ['snippet', 'contentDetails'], id: idsChunk, access_token }))).then((responses) =>
@@ -568,16 +473,13 @@ export class YouTubeMusic implements Connection {
)
// Oh FFS. Despite nothing documenting it ^this api can only query a maximum of 50 ids at a time. Addtionally, if you exceed that limit, it doesn't even give you the correct error, it says some nonsense about an invalid filter paramerter. FML.
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
const getPlaylistDetails = () => Promise.all(Array.from(playlistIds).map((id) => this.getPlaylist(id)))
const [songDetails, albumDetails, playlistDetails] = await Promise.all([getSongDetails(), getAlbumDetails(), getPlaylistDetails()])
const [songDetails, albumDetails] = await Promise.all([getSongDetails(), getAlbumDetails()])
const songDetailsMap = new Map<string, youtube_v3.Schema$Video>(),
albumDetailsMap = new Map<string, Album>(),
playlistDetailsMap = new Map<string, Playlist>()
albumDetailsMap = new Map<string, Album>()
songDetails.forEach((item) => songDetailsMap.set(item.id!, item))
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
playlistDetails.forEach((playlist) => playlistDetailsMap.set(playlist.id, playlist))
const connection = { id: this.id, type: 'youtube-music' } satisfies (Song | Album | Artist | Playlist)['connection']
@@ -591,21 +493,25 @@ export class YouTubeMusic implements Connection {
const thumbnails = songDetails.snippet?.thumbnails!
const thumbnailUrl = item.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
const releaseDate = new Date(songDetails.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? songDetails.snippet?.publishedAt!).toISOString()
const songAlbum = item.album?.id ? albumDetailsMap.get(item.album.id)! : undefined
const album = songAlbum ? { id: songAlbum.id, name: songAlbum.name } : undefined
let album: Song['album']
if (item.album?.id) {
const albumName = item.album.name ? item.album.name : albumDetailsMap.get(item.album.id)!.name
album = { id: item.album.id, name: albumName }
}
const releaseDate = new Date(songDetails.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? songDetails.snippet?.publishedAt!).toISOString()
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo, uploader } satisfies Song
case 'album':
return albumDetailsMap.get(item.id)! satisfies Album
const releaseYear = albumDetailsMap.get(item.id)?.releaseYear // For in the unlikely event that and album got added by a song
// ? Honestly, I don't think it is worth it to send out a request to the album endpoint for every album just to get the release year.
// ? Maybe it will be justifyable in the future if I decide to add more details to the album type that can only be retrieved from the album endpoint.
// ? I guess as long as it's at most a dozen requests or so each time it's fine. But when I get to things larger queries like a user's library, this could become very bad very fast.
// ? Maybe I should add a "fields" paramter to the album, artist, and playlist types that can include addtional, but not necessary info like release year that can be requested in
// ? the specific methods, but left out for large query methods like this.
return Object.assign(item, { connection, releaseYear }) satisfies Album
case 'artist':
return { connection, id: item.id, name: item.name, type: 'artist', profilePicture: item.profilePicture } satisfies Artist
return Object.assign(item, { connection }) satisfies Artist
case 'playlist':
return playlistDetailsMap.get(item.id)! satisfies Playlist
// * If there are ever problems with playlist thumbanails being incorrect (black bars, etc.) look into using the official api to get playlist thumbnails (getPlaylist() is inefficient)
return Object.assign(item, { connection }) satisfies Playlist
}
}) as ScrapedMediaItemMap<T[number]>[]
}
@@ -638,7 +544,7 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
if ('watchEndpoint' in rowContent.navigationEndpoint) {
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
const isVideo = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl: InnerTube.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const thumbnailUrl: InnerTube.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : extractLargestThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let albumId: string | undefined
rowContent.menu?.menuRenderer.items.forEach((menuOption) => {
@@ -657,17 +563,16 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const id = rowContent.navigationEndpoint.browseEndpoint.browseId
const image = extractLargestThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
const thumbnailUrl = cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'album', artists, thumbnailUrl } satisfies InnerTube.ScrapedAlbum
return { id, name, type: 'album', artists, thumbnailUrl: image } satisfies InnerTube.ScrapedAlbum
case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
const profilePicture = cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
return { id, name, type: 'artist', profilePicture: image } satisfies InnerTube.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
return { id: id.slice(2), name, type: 'playlist', thumbnailUrl: image, createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
default:
throw Error('Unexpected twoRowItem type: ' + pageType)
}
@@ -705,7 +610,7 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
const isVideo =
listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !==
'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const thumbnailUrl = isVideo ? undefined : extractLargestThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
const album =
@@ -718,17 +623,16 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
const id = listContent.navigationEndpoint.browseEndpoint.browseId
const pageType = listContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const image = extractLargestThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
const thumbnailUrl = cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'album', thumbnailUrl, artists } satisfies InnerTube.ScrapedAlbum
return { id, name, type: 'album', thumbnailUrl: image, artists } satisfies InnerTube.ScrapedAlbum
case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
const profilePicture = cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
return { id, name, type: 'artist', profilePicture: image } satisfies InnerTube.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
return { id: id.slice(2), name, type: 'playlist', thumbnailUrl: image, createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
default:
throw Error('Unexpected responsiveListItem type: ' + pageType)
}
@@ -763,28 +667,254 @@ function parseMusicCardShelfRenderer(cardContent: InnerTube.musicCardShelfRender
if ('watchEndpoint' in navigationEndpoint) {
const id = navigationEndpoint.watchEndpoint.videoId
const isVideo = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const thumbnailUrl = isVideo ? undefined : extractLargestThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.ScrapedSong
}
const pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const id = navigationEndpoint.browseEndpoint.browseId
const image = extractLargestThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
const thumbnailUrl = cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'album', thumbnailUrl, artists } satisfies InnerTube.ScrapedAlbum
return { id, name, type: 'album', thumbnailUrl: image, artists } satisfies InnerTube.ScrapedAlbum
case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
const profilePicture = cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
return { id, name, type: 'artist', profilePicture: image } satisfies InnerTube.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
return { id: id.slice(2), name, type: 'playlist', thumbnailUrl: image, createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
default:
throw Error('Unexpected musicCardShelf type: ' + pageType)
}
}
class YTRequestManager {
private readonly connectionId: string
private currentAccessToken: string
private readonly refreshToken: string
private expiry: number
private readonly searchFilterParams = {
song: 'EgWKAQIIAWoMEA4QChADEAQQCRAF',
album: 'EgWKAQIYAWoMEA4QChADEAQQCRAF',
artist: 'EgWKAQIgAWoMEA4QChADEAQQCRAF',
playlist: 'Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D',
} as const
constructor(connectionId: string, accessToken: string, refreshToken: string, expiry: number) {
this.connectionId = connectionId
this.currentAccessToken = accessToken
this.refreshToken = refreshToken
this.expiry = expiry
}
private accessTokenRefreshRequest: Promise<string> | null = null
public 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,
refresh_token: this.refreshToken,
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 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}`)
}
if (this.expiry > Date.now()) return new Promise<string>((resolve) => resolve(this.currentAccessToken))
if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
this.accessTokenRefreshRequest = refreshAccessToken()
.then(({ accessToken, expiry }) => {
DB.updateTokens(this.connectionId, { accessToken, refreshToken: this.refreshToken, expiry })
this.currentAccessToken = accessToken
this.expiry = expiry
this.accessTokenRefreshRequest = null
return accessToken
})
.catch((error: Error) => {
this.accessTokenRefreshRequest = null
throw error
})
return this.accessTokenRefreshRequest
}
public async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) {
const headers = new Headers({
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
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`,
},
}
let url: string
let body: Record<string, any>
switch (requestDetails.type) {
case 'browse':
url = 'https://music.youtube.com/youtubei/v1/browse'
body = {
browseId: requestDetails.browseId,
context,
}
break
case 'search':
url = 'https://music.youtube.com/youtubei/v1/search'
body = {
query: requestDetails.searchTerm,
filter: requestDetails.filter ? this.searchFilterParams[requestDetails.filter] : undefined,
context,
}
break
case 'continuation':
url = `https://music.youtube.com/youtubei/v1/browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`
body = {
context,
}
break
}
return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) })
}
}
class YTLibaryManager {
private readonly connectionId: string
private readonly requestManager: YTRequestManager
private readonly youtubeUserId: string
constructor(connectionId: string, youtubeUserId: string, requestManager: YTRequestManager) {
this.connectionId = connectionId
this.requestManager = requestManager
this.youtubeUserId = youtubeUserId
}
public async albums(): Promise<Album[]> {
const albumData = await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_liked_albums' }).then((response) => response.json() as Promise<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
.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation })
.then((response) => response.json() as Promise<InnerTube.Library.AlbumContinuationResponse>)
items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
}
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
return items.map((item) => {
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId
const name = item.musicTwoRowItemRenderer.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let artists: Album['artists'] = []
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
if (run.text === 'Various Artists') return (artists = 'Various Artists')
if (run.navigationEndpoint && artists instanceof Array) artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
})
const releaseYear = item.musicTwoRowItemRenderer.subtitle.runs.at(-1)?.text!
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
})
}
public async artists(): Promise<Artist[]> {
const artistsData = await this.requestManager
.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_library_corpus_track_artists' })
.then((response) => response.json() as Promise<InnerTube.Library.ArtistResponse>)
const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer
let continuation = continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationData = await this.requestManager
.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation })
.then((response) => response.json() as Promise<InnerTube.Library.ArtistContinuationResponse>)
contents.push(...continuationData.continuationContents.musicShelfContinuation.contents)
continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
}
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
return contents.map((item) => {
const id = item.musicResponsiveListItemRenderer.navigationEndpoint.browseEndpoint.browseId
const name = item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const profilePicture = extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
return { connection, id, name, type: 'artist', profilePicture } satisfies Artist
})
}
public async playlists(): Promise<Playlist[]> {
const playlistData = await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_liked_playlists' }).then((response) => response.json() as Promise<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
.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation })
.then((response) => response.json() as Promise<InnerTube.Library.PlaylistContinuationResponse>)
items.push(...continuationData.continuationContents.gridContinuation.items)
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
}
const playlists = items.filter(
(item): item is { musicTwoRowItemRenderer: InnerTube.Library.PlaylistMusicTwoRowItemRenderer } =>
'browseEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint &&
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLLM' &&
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLSE',
)
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
return playlists.map((item) => {
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId.slice(2)
const name = item.musicTwoRowItemRenderer.title.runs[0].text
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
let createdBy: Playlist['createdBy']
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
if (run.navigationEndpoint && run.navigationEndpoint.browseEndpoint.browseId !== this.youtubeUserId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
})
}
}
/**
* @param duration Timestamp in standard ISO8601 format PnDTnHnMnS
* @returns The duration of the timestamp in seconds
@@ -812,20 +942,22 @@ function secondsFromISO8601(duration: string): number {
* It is generally best practice to not directly scrape these video thumbnails directly from youtube and insted get the highest res from the v3 api.
* However there a few instances in which we want to scrape a thumbail directly from the webapp (e.g. Playlist thumbanils) so it still remains valid.
*/
function cleanThumbnailUrl(urlString: string): string {
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: number; height: number }>): string {
const bestThumbnailURL = thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url
if (!URL.canParse(bestThumbnailURL)) throw new Error('Invalid thumbnail url')
switch (new URL(urlString).origin) {
switch (new URL(bestThumbnailURL).origin) {
case 'https://lh3.googleusercontent.com':
case 'https://yt3.googleusercontent.com':
case 'https://yt3.ggpht.com':
return urlString.slice(0, urlString.indexOf('='))
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('='))
case 'https://music.youtube.com':
return urlString
return bestThumbnailURL
case 'https://www.gstatic.com': // This url will usually contain static images like a placeholder artist profile picture for example
case 'https://i.ytimg.com':
return urlString.slice(0, urlString.indexOf('?'))
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('?'))
default:
console.error('Tried to clean invalid url: ' + urlString)
console.error('Tried to clean invalid url: ' + bestThumbnailURL)
throw new Error('Invalid thumbnail url origin')
}
}

View File

@@ -8,69 +8,119 @@ export const newestAlert: Writable<[AlertType, string]> = writable()
const youtubeMusicBackground: string = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // Default Youtube music background
export const backgroundImage: Writable<string> = writable(youtubeMusicBackground)
function fisherYatesShuffle<T>(items: T[]) {
for (let currentIndex = items.length - 1; currentIndex >= 0; currentIndex--) {
let randomIndex = Math.floor(Math.random() * (currentIndex + 1))
;[items[currentIndex], items[randomIndex]] = [items[randomIndex], items[currentIndex]]
}
return items
}
// ? New idea for how to handle mixing. Keep originalSongs and currentSongs but also add playedSongs. Add the previous song to played songs whenever next() is called.
// ? Whenever a song is mixed, set currentSongs = [...playedSongs, currentSongs[currentPosition], ...fisherYatesShuffle(everything else)]. Reorder method would stay the same.
// ? IDK it's a thought
class Queue {
private currentPosition: number // -1 means there is no current position
private songs: Song[]
private currentPosition: number // -1 means no song is playing
private originalSongs: Song[]
private currentSongs: Song[]
private shuffled: boolean
constructor() {
this.currentPosition = -1
this.songs = []
this.originalSongs = []
this.currentSongs = []
this.shuffled = false
}
get current() {
if (this.songs.length === 0) return null
if (this.currentSongs.length === 0) return null
if (this.currentPosition === -1) this.currentPosition = 0
return this.songs[this.currentPosition]
return this.currentSongs[this.currentPosition]
}
/** Sets the currently playing song to the song provided as long as it is in the current playlist */
set current(newSong: Song | null) {
if (newSong === null) {
this.currentPosition = -1
} else {
const queuePosition = this.songs.findIndex((song) => song === newSong)
if (queuePosition < 0) {
this.songs = [newSong]
this.currentPosition = 0
} else {
this.currentPosition = queuePosition
}
const queuePosition = this.currentSongs.findIndex((song) => song === newSong)
if (queuePosition >= 0) this.currentPosition = queuePosition
}
writableQueue.set(this)
}
get list() {
return this.songs
return this.currentSongs
}
get isShuffled() {
return this.shuffled
}
/** Shuffles all songs in the queue after the currently playing song */
public shuffle() {
const shuffledSongs = fisherYatesShuffle(this.currentSongs.slice(this.currentPosition + 1))
this.currentSongs = this.currentSongs.slice(0, this.currentPosition + 1).concat(shuffledSongs)
this.shuffled = true
writableQueue.set(this)
}
/** Restores the queue to its original ordered state, while maintaining whatever song is currently playing */
public reorder() {
const originalPosition = this.originalSongs.findIndex((song) => song === this.currentSongs[this.currentPosition])
this.currentSongs = [...this.originalSongs]
this.currentPosition = originalPosition
this.shuffled = false
writableQueue.set(this)
}
/** Starts the next song */
public next() {
if (this.songs.length === 0 || this.songs.length <= this.currentPosition + 1) return
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return
this.currentPosition += 1
writableQueue.set(this)
}
/** Plays the previous song */
public previous() {
if (this.songs.length === 0 || this.currentPosition <= 0) return
if (this.currentSongs.length === 0 || this.currentPosition <= 0) return
this.currentPosition -= 1
writableQueue.set(this)
}
/** Add songs to the end of the queue */
public enqueue(...songs: Song[]) {
this.songs.push(...songs)
this.originalSongs.push(...songs)
this.currentSongs.push(...songs)
writableQueue.set(this)
}
public setQueue(...songs: Song[]) {
this.songs = songs
this.currentPosition = songs.length === 0 ? -1 : 0
/**
* @param songs An ordered array of Songs
* @param shuffled Whether or not to shuffle the queue before starting playback. False if not specified
*/
public setQueue(params: { songs: Song[]; shuffled?: boolean }) {
if (params.songs.length === 0) return // Should not set a queue with no songs, use clear()
this.originalSongs = params.songs
this.currentSongs = params.shuffled ? fisherYatesShuffle(params.songs) : params.songs
this.currentPosition = 0
this.shuffled = params.shuffled ?? false
writableQueue.set(this)
}
/** Clears all items from the queue */
public clear() {
this.currentPosition = -1
this.songs = []
this.originalSongs = []
this.currentSongs = []
writableQueue.set(this)
}
}