From de20ee90b557008e8ebef60415d89b2a240906ef Mon Sep 17 00:00:00 2001 From: Eclypsed Date: Sun, 23 Jun 2024 17:13:09 -0400 Subject: [PATCH] Few small db and ytmusic changes --- src/app.d.ts | 8 +- src/hooks.server.ts | 10 +- src/lib/server/api-helper.ts | 8 +- src/lib/server/db.ts | 25 +- src/lib/server/youtube-music-types.d.ts | 989 +++++++++++------------- src/lib/server/youtube-music.ts | 675 +++++----------- src/routes/login/+page.server.ts | 5 +- 7 files changed, 675 insertions(+), 1045 deletions(-) diff --git a/src/app.d.ts b/src/app.d.ts index 0efefb6..bc0af24 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -113,10 +113,6 @@ declare global { */ getPlaylistItems(id: string, options?: { startIndex?: number, limit?: number }): Promise - public readonly songs?: { // Optional because YouTube Music can't be asked to provide an actually useful API. - songs(ids: string[]): Promise - } - public readonly library: { albums(): Promise artists(): Promise @@ -145,6 +141,7 @@ declare global { artists?: { // Should try to order id: string name: string + profilePicture?: string }[] album?: { id: string @@ -153,6 +150,7 @@ declare global { uploader?: { id: string name: string + profilePicture?: string } isVideo: boolean } @@ -170,6 +168,7 @@ declare global { artists: { // Should try to order id: string name: string + profilePicture?: string }[] | 'Various Artists' releaseYear?: string // #### } @@ -198,6 +197,7 @@ declare global { createdBy?: { // Optional, in the case that a playlist is auto-generated or it's the user's playlist in which case this is unnecessary id: string name: string + profilePicture?: string } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e0d9419..3d0e76e 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -15,20 +15,16 @@ function verifyAuthToken(event: RequestEvent) { } } -const unauthorizedResponse = new Response('Unauthorized.', { status: 401 }) -const userNotFoundResponse = new Response('User not found.', { status: 404 }) -const mixNotFoundResponse = new Response('Mix not found.', { status: 404 }) - // * Custom Handle specifically for requests made to the API endpoint. Handles authorization and any other middleware verifications const handleAPIRequest: Handle = async ({ event, resolve }) => { const authorized = event.request.headers.get('apikey') === SECRET_INTERNAL_API_KEY || event.url.searchParams.get('apikey') === SECRET_INTERNAL_API_KEY || verifyAuthToken(event) - if (!authorized) unauthorizedResponse + if (!authorized) return new Response('Unauthorized', { status: 401 }) const userId = event.params.userId - if (userId && !(await userExists(userId))) return userNotFoundResponse + if (userId && !(await userExists(userId))) return new Response(`User ${userId} not found`, { status: 404 }) const mixId = event.params.mixId - if (mixId && !(await mixExists(mixId))) return mixNotFoundResponse + if (mixId && !(await mixExists(mixId))) return new Response(`Mix ${mixId} not found`, { status: 404 }) return resolve(event) } diff --git a/src/lib/server/api-helper.ts b/src/lib/server/api-helper.ts index a8cabcb..dcab5f0 100644 --- a/src/lib/server/api-helper.ts +++ b/src/lib/server/api-helper.ts @@ -1,16 +1,16 @@ -import { DB, type DBSchemas } from './db' +import { DB, type Schemas } from './db' import { Jellyfin } from './jellyfin' import { YouTubeMusic } from './youtube-music' export async function userExists(userId: string): Promise { - return Boolean(await DB.users.where('id', userId).first(DB.knex.raw('EXISTS(SELECT 1)'))) + return Boolean(await DB.users.where('id', userId).first(DB.db.raw('EXISTS(SELECT 1)'))) } export async function mixExists(mixId: string): Promise { - return Boolean(await DB.mixes.where('id', mixId).first(DB.knex.raw('EXISTS(SELECT 1)'))) + return Boolean(await DB.mixes.where('id', mixId).first(DB.db.raw('EXISTS(SELECT 1)'))) } -function connectionBuilder(schema: DBSchemas.Connections): Connection { +function connectionBuilder(schema: Schemas.Connections): Connection { const { id, userId, type, serviceUserId, accessToken } = schema switch (type) { case 'jellyfin': diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index c980e27..877ad03 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -1,9 +1,8 @@ import knex from 'knex' -import { SqliteError } from 'better-sqlite3' const connectionTypes = ['jellyfin', 'youtube-music'] -export declare namespace DBSchemas { +export declare namespace Schemas { interface Users { id: string username: string @@ -74,39 +73,37 @@ export declare namespace DBSchemas { } class Database { - public readonly knex: knex.Knex + public readonly db: knex.Knex - constructor(knex: knex.Knex<'better-sqlite3'>) { - this.knex = knex + constructor(db: knex.Knex<'better-sqlite3'>) { + this.db = db } public uuid() { - return this.knex.fn.uuid() + return this.db.fn.uuid() } public get users() { - return this.knex('Users') + return this.db('Users') } public get connections() { - return this.knex('Connections') + return this.db('Connections') } public get mixes() { - return this.knex('Mixes') + return this.db('Mixes') } public get mixItems() { - return this.knex('MixItems') + return this.db('MixItems') } public get songs() { - return this.knex('Songs') + return this.db('Songs') } - public get sqliteError() { - return SqliteError - } + private exists() {} public static async createUsersTable(db: knex.Knex<'better-sqlite3'>) { const exists = await db.schema.hasTable('Users') diff --git a/src/lib/server/youtube-music-types.d.ts b/src/lib/server/youtube-music-types.d.ts index 7f8cceb..c72e22d 100644 --- a/src/lib/server/youtube-music-types.d.ts +++ b/src/lib/server/youtube-music-types.d.ts @@ -18,59 +18,10 @@ // However for albums use the browseId because you need it to query the v1 ytmusic api, and there is no way to get that from the playlistId. Additionally // we don't really need the album's playlistId because the official youtube data API is so useless it doesn't provide anything of value that can't // also be scraped from the browseId response. +// +// 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 { - type ScrapedSong = { - id: string - name: string - type: 'song' - thumbnailUrl?: string - artists?: { - id: string - name: string - }[] - album?: { - id: string - name?: string - } - uploader?: { - id: string - name: string - } - isVideo: boolean - } - - type ScrapedAlbum = { - id: string - name: string - type: 'album' - thumbnailUrl: string - artists: - | { - id: string - name: string - }[] - | 'Various Artists' - } - - type ScrapedArtist = { - id: string - name: string - type: 'artist' - profilePicture: string - } - - type ScrapedPlaylist = { - id: string - name: string - type: 'playlist' - thumbnailUrl: string - createdBy: { - id: string - name: string - } - } - namespace Library { interface AlbumResponse { contents: { @@ -124,7 +75,15 @@ export namespace InnerTube { type AlbumMusicTwoRowItemRenderer = { thumbnailRenderer: { - musicThumbnailRenderer: musicThumbnailRenderer + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } } title: { runs: [ @@ -209,7 +168,15 @@ export namespace InnerTube { type ArtistMusicResponsiveListItemRenderer = { thumbnail: { - musicThumbnailRenderer: musicThumbnailRenderer + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } } flexColumns: [ { @@ -309,7 +276,15 @@ export namespace InnerTube { type PlaylistMusicTwoRowItemRenderer = { thumbnailRenderer: { - musicThumbnailRenderer: musicThumbnailRenderer + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } } title: { runs: [ @@ -338,18 +313,45 @@ export namespace InnerTube { } namespace Playlist { - interface PlaylistResponse { + interface Response { contents: { - singleColumnBrowseResultsRenderer: { + twoColumnBrowseResultsRenderer: { + secondaryContents: { + sectionListRenderer: { + contents: [ + { + musicPlaylistShelfRenderer: { + contents: Array<{ + musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer + }> + continuations?: [ + { + nextContinuationData: { + continuation: string + } + }, + ] + } + }, + ] + } + } tabs: [ { tabRenderer: { content: { sectionListRenderer: { contents: [ - { - musicPlaylistShelfRenderer: ContentShelf - }, + | { + musicEditablePlaylistDetailHeaderRenderer: { + header: { + musicResponsiveHeaderRenderer: MusicResponsiveHeaderRenderer + } + } + } + | { + musicResponsiveHeaderRenderer: MusicResponsiveHeaderRenderer + }, ] } } @@ -358,16 +360,26 @@ export namespace InnerTube { ] } } - header: - | Header - | { - musicEditablePlaylistDetailHeaderRenderer: { - header: Header - } - } } - interface PlaylistErrorResponse { + interface ContinuationResponse { + continuationContents: { + musicPlaylistShelfContinuation: { + contents: Array<{ + musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer + }> + continuations?: [ + { + nextContinuationData: { + continuation: string + } + }, + ] + } + } + } + + interface ErrorResponse { error: { code: number message: string @@ -375,120 +387,191 @@ export namespace InnerTube { } } - interface ContinuationResponse { - continuationContents: { - musicPlaylistShelfContinuation: ContentShelf + type MusicResponsiveHeaderRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + title: { + runs: [ + { + text: string + }, + ] + } + subtitle: { + runs: Array<{ + text: string // Last one is the release year + }> + } + straplineTextOne: { + runs: [ + { + text: string + navigationEndpoint?: { + // If the playlist is an auto-generated radio it will not have this + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL' // Should ALWAYS be user channel, even if the playlist was created by an artist + } + } + } + } + }, + ] + } + straplineThumbnail?: { + // The profile picture of the user that created the playlist. Missing in radios + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + description?: { + musicDescriptionShelfRenderer: { + description: { + runs: [ + { + text: string + }, + ] + } + } } } - type ContentShelf = { - contents: Array - continuations?: [ + type MusicResponsiveListItemRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + flexColumns: [ { - nextContinuationData: { - continuation: string + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string // Song Name + navigationEndpoint: { + watchEndpoint: { + videoId: string + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: { + musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_ATV' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC' + } + } + } + } + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: Array<{ + text: string // Name of Artist or Uploader or a Delimiter + navigationEndpoint?: { + // Not present on delimiters + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL' + } + } + } + } + }> + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs?: [ + // Undefined if song does not have an album + { + text: string + navigationEndpoint: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ALBUM' + } + } + } + } + }, + ] + } } }, ] - } - - type PlaylistItem = { - musicResponsiveListItemRenderer: { - thumbnail: { - musicThumbnailRenderer: musicThumbnailRenderer - } - flexColumns: [ - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs: [ - { - text: string - navigationEndpoint?: { - watchEndpoint: watchEndpoint - } - }, - ] - } + fixedColumns: [ + { + musicResponsiveListItemFixedColumnRenderer: { + text: { + runs: [ + { + text: string // Duration timestamp + }, + ] } - }, - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs: { - text: string - navigationEndpoint?: { - browseEndpoint: browseEndpoint - } - }[] - } - } - }, - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs?: [ - { - text: string - navigationEndpoint: { - browseEndpoint: browseEndpoint - } - }, - ] - } - } - }, - ] - fixedColumns: [ - { - musicResponsiveListItemFixedColumnRenderer: { - text: { - runs: [ - { - text: string - }, - ] - } - } - }, - ] - } - } - - type Header = { - musicDetailHeaderRenderer: { - title: { - runs: [ - { - text: string - }, - ] - } - subtitle: { - runs: { - text: string - navigationEndpoint?: { - browseEndpoint: browseEndpoint - } - }[] - } - secondSubtitle: { - // Will contain info like view count, track count, duration etc. (Don't try and scrape duration from this, it sucks. There's not much you can do with "7+ hours") - runs: { - text: string - }[] - } - thumbnail: { - croppedSquareThumbnailRenderer: musicThumbnailRenderer - } - } + } + }, + ] } } namespace Album { interface AlbumResponse { contents: { - singleColumnBrowseResultsRenderer: { + twoColumnBrowseResultsRenderer: { + secondaryContents: { + sectionListRenderer: { + contents: [ + { + musicShelfRenderer: { + contents: Array<{ + musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer + }> + } + }, + ] + continuations?: [ + // Not actually sure if this will ever show up, I'm just assuming this would work like playlists + { + nextContinuationData: { + continuation: string + } + }, + ] + } + } tabs: [ { tabRenderer: { @@ -496,15 +579,57 @@ export namespace InnerTube { sectionListRenderer: { contents: [ { - musicShelfRenderer: { - contents: Array - continuations?: [ - { - nextContinuationData: { - continuation: string + musicResponsiveHeaderRenderer: { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> } - }, - ] + } + } + title: { + runs: [ + { + text: string // Album Name + }, + ] + } + subtitle: { + runs: Array<{ + text: string // Last one is the release year + }> + } + straplineTextOne: { + runs: Array<{ + text: string // Artist name or 'Various Artists' or some other kind of unlinked string like 'Camellia & Akira Complex' + navigationEndpoint?: { + // Absesnt on single string descriptors and delimiters + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ARTIST' + } + } + } + } + }> + } + straplineThumbnail?: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } } }, ] @@ -515,102 +640,94 @@ export namespace InnerTube { ] } } - header: { - musicDetailHeaderRenderer: { - title: { - runs: [ - { - text: string - }, - ] - } - subtitle: { - // Alright let's break down this dumbass pattern. First run will always have the text 'Album', last will always be the release year. Interspersed throughout the middle will be the artist runs - // which, if they have a dedicated channel, will have a navigation endpoint. Every other run is some kind of delimiter (• , &). Because y'know, it's perfectly sensible to include your decorative - // elements in your api responses /s - runs: Array<{ - text: string - navigationEndpoint?: { - browseEndpoint: browseEndpoint + } + + interface ContinuationResponse { + // Again, never actually seen this before but I'm assuming this is how it works + continuationContents: { + musicShelfRenderer: { + contents: Array<{ + musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer + }> + continuations?: [ + { + nextContinuationData: { + continuation: string } - }> - } - secondSubtitle: { - // Slightly less dumbass. Three runs, first is the number of songs in the format: "# songs". Second is another bullshit delimiter. Last is the album's duration, spelled out rather than as a timestamp - // for god knows what reason. Duration follows the following format: "# hours, # minutes" or just "# minutes". - runs: { - text: string - }[] - } - thumbnail: { - croppedSquareThumbnailRenderer: musicThumbnailRenderer - } + }, + ] } } } - type AlbumItem = { - musicResponsiveListItemRenderer: { - flexColumns: [ - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs: [ - { - text: string - navigationEndpoint: { - watchEndpoint: watchEndpoint - } - }, - ] - } - } - }, - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs?: { - text: string - navigationEndpoint?: { - browseEndpoint: browseEndpoint - } - }[] - } - } - }, - ] - fixedColumns: [ - { - musicResponsiveListItemFixedColumnRenderer: { - text: { - runs: [ - { - text: string - }, - ] - } - } - }, - ] + interface ErrorResponse { + error: { + code: number + message: string + status: string } } - type ContentShelf = { - contents: Array - continuations?: [ + type MusicResponsiveListItemRenderer = { + flexColumns: [ { - nextContinuationData: { - continuation: string + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string // Song Name + navigationEndpoint: { + watchEndpoint: { + videoId: string + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: { + musicVideoType: 'MUSIC_VIDEO_TYPE_ATV' // It *should* only ever be auto-generated + } + } + } + } + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs?: Array<{ + // If runs is missing that means all tracks have the same artist, and we can assert that there is definitely an artist with an id in straplineTextOne + text: string // Artist Name + navigationEndpoint?: { + // Missing if there is a delimiter between multiple artists + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ARTIST' + } + } + } + } + }> + } + } + }, + // The IS a third column but it only contains play count + ] + fixedColumns: [ + { + musicResponsiveListItemFixedColumnRenderer: { + text: { + runs: [ + { + text: string // Duration timestamp + }, + ] + } } }, ] } - - interface ContinuationResponse { - continuationContents: { - musicShelfContinuation: ContentShelf - } - } } namespace Player { @@ -644,300 +761,96 @@ export namespace InnerTube { } } - interface SearchResponse { - contents: { - tabbedSearchResultsRenderer: { - tabs: [ - { - tabRenderer: { - title: string - content: { - sectionListRenderer: { - contents: Array< - | { - musicCardShelfRenderer: musicCardShelfRenderer - } - | { - musicShelfRenderer: musicShelfRenderer - } - | { - itemSectionRenderer: unknown - } - > - } - } - } - }, - ] - } - } - } - - type musicCardShelfRenderer = { - title: { - runs: [ - { - text: string // Unlike musicShelfRenderer, this is the name of the top search result, be that the name of a song, album, artist, or etc. - navigationEndpoint: - | { - watchEndpoint: watchEndpoint - } - | { - browseEndpoint: browseEndpoint - } - }, - ] - } - subtitle: { - runs: Array<{ - text: string - navigationEndpoint?: { - browseEndpoint: browseEndpoint - } - }> - } - contents?: Array< - | { - messageRenderer: unknown - } - | { - musicResponsiveListItemRenderer: musicResponsiveListItemRenderer - } - > - thumbnail: { - musicThumbnailRenderer: musicThumbnailRenderer - } - } - - type musicShelfRenderer = { - title: { - runs: [ - { - text: 'Artists' | 'Songs' | 'Videos' | 'Albums' | 'Community playlists' | 'Podcasts' | 'Episodes' | 'Profiles' - }, - ] - } - contents: Array<{ - musicResponsiveListItemRenderer: musicResponsiveListItemRenderer - }> - } - - interface HomeResponse { - contents: { - singleColumnBrowseResultsRenderer: { - tabs: [ - { - tabRenderer: { - content: { - sectionListRenderer: { - contents: Array<{ - musicCarouselShelfRenderer: musicCarouselShelfRenderer - }> - } - } - } - }, - ] - } - } - } - - type musicCarouselShelfRenderer = { - header: { - musicCarouselShelfBasicHeaderRenderer: { - title: { - runs: [ - { - text: string - }, - ] - } - } - } - contents: - | Array<{ - musicTwoRowItemRenderer: musicTwoRowItemRenderer - }> - | Array<{ - musicResponsiveListItemRenderer: musicResponsiveListItemRenderer - }> - } - - type musicTwoRowItemRenderer = { - thumbnailRenderer: { - musicThumbnailRenderer: musicThumbnailRenderer - } - title: { - runs: [ - { - text: string - }, - ] - } - subtitle: { - runs: Array<{ - text: string - navigationEndpoint?: { - browseEndpoint: browseEndpoint - } - }> - } - navigationEndpoint: - | { - watchEndpoint: watchEndpoint - } - | { - browseEndpoint: browseEndpoint - } - menu?: { - menuRenderer: { - items: Array< + namespace Queue { + interface Response { + queueDatas: Array<{ + content: | { - menuNavigationItemRenderer: { - text: { - runs: [ - { - text: 'Go to album' | 'Go to artist' - }, - ] + playlistPanelVideoRenderer: PlaylistPanelVideoRenderer // 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 } - navigationEndpoint: - | { - browseEndpoint: browseEndpoint - } - | { - watchPlaylistEndpoint: unknown - } - | { - addToPlaylistEndpoint: unknown - } - | { - shareEntityEndpoint: unknown - } - | { - watchEndpoint: unknown - } - } - } - | { - menuServiceItemRenderer: unknown - } - | { - toggleMenuServiceItemRenderer: unknown - } - > - } - } - } - - type musicResponsiveListItemRenderer = { - thumbnail: { - musicThumbnailRenderer: musicThumbnailRenderer - } - } & ( - | { - flexColumns: [ - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs: [ + counterpart: [ { - text: string - navigationEndpoint: { - watchEndpoint: watchEndpoint + counterpartRenderer: { + playlistPanelVideoRenderer: PlaylistPanelVideoRenderer } }, ] } } - }, - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs: Array<{ - text: string - navigationEndpoint?: { - browseEndpoint: browseEndpoint - } - }> - } - } - }, - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs?: [ - { - text: string - navigationEndpoint?: { - browseEndpoint: browseEndpoint - } - }, - ] - } - } - }?, - ] - } - | { - flexColumns: [ - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs: [ - { - text: string - }, - ] - } - } - }, - { - musicResponsiveListItemFlexColumnRenderer: { - text: { - runs: Array<{ - text: string - navigationEndpoint?: { - browseEndpoint: browseEndpoint - } - }> - } - } - }, - ] - navigationEndpoint: { - browseEndpoint: browseEndpoint - } - } - ) - - type musicThumbnailRenderer = { - thumbnail: { - thumbnails: Array<{ - url: string - width: number - height: number }> } - } - type browseEndpoint = { - browseId: string - browseEndpointContextSupportedConfigs: { - browseEndpointContextMusicConfig: { - pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL' + interface ErrorResponse { + error: { + code: number + message: string + status: string + } + } + + type PlaylistPanelVideoRenderer = { + title: { + runs: [ + { + text: string + }, + ] + } + longBylineText: { + runs: Array<{ + text: string + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL' + } + } + } + } + }> + } + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + lengthText: { + runs: [ + { + text: string // The duration in timestamp format - hh:mm:ss + }, + ] + } + videoId: string + navigationEndpoint: { + watchEndpoint: { + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: { + musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_ATV' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC' + } + } + } } } } - type watchEndpoint = { - videoId: string - playlistId: string - watchEndpointMusicSupportedConfigs: { - watchEndpointMusicConfig: { - musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_ATV' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC' - // UGC and OMV Means it is a user-uploaded video, ATV means it is auto-generated, I don't have a fucking clue what OFFICIAL_SOURCE_MUSIC means but so far it seems like videos too? - } - } + // TODO: Need to fix this & it's corresponding method & add appropriate namespace + interface SearchResponse { + contents: unknown + } + + // TODO: Need to fix this & it's corresponding method & add appropriate namespace + interface HomeResponse { + contents: unknown } } diff --git a/src/lib/server/youtube-music.ts b/src/lib/server/youtube-music.ts index b1acda1..f3bfdf1 100644 --- a/src/lib/server/youtube-music.ts +++ b/src/lib/server/youtube-music.ts @@ -20,16 +20,10 @@ type ytMusicv1ApiRequestParams = type: 'continuation' ctoken: string } - -type ScrapedMediaItemMap = MediaItem extends InnerTube.ScrapedSong - ? Song - : MediaItem extends InnerTube.ScrapedAlbum - ? Album - : MediaItem extends InnerTube.ScrapedArtist - ? Artist - : MediaItem extends InnerTube.ScrapedPlaylist - ? Playlist - : never + | { + type: 'queue' + videoIds: string[] + } // 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 { @@ -55,10 +49,7 @@ export class YouTubeMusic implements Connection { } public async getConnectionInfo() { - const access_token = await this.requestManager.accessToken.catch(() => { - console.log('Failed to get yt access token') - return null - }) + const access_token = await this.requestManager.accessToken.catch(() => null) let username: string | undefined, profilePicture: string | undefined if (access_token) { @@ -68,7 +59,14 @@ export class YouTubeMusic implements Connection { profilePicture = userChannel?.snippet?.thumbnails?.default?.url ?? undefined } - return { id: this.id, userId: this.userId, type: 'youtube-music', youtubeUserId: this.youtubeUserId, username, profilePicture } satisfies ConnectionInfo + 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) @@ -78,84 +76,25 @@ export class YouTubeMusic implements Connection { public async search(searchTerm: string, filter: 'playlist'): Promise 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)[]> { - // Figure out how to handle Library and Uploads - // Depending on how I want to handle the playlist & library sync feature + return [] // ! Need to completely rework this method - 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 - - try { - const parsedSearchResults = [] - const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists'] - for (const section of contents) { - if ('itemSectionRenderer' in section) continue - - if ('musicCardShelfRenderer' in section) { - parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer)) - section.musicCardShelfRenderer.contents?.forEach((item) => { - if ('musicResponsiveListItemRenderer' in item) { - try { - // ! TEMPORARY I need to rework all my parsers to be able to handle edge cases - parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) - } catch { - return - } - } - }) - continue - } - - const sectionType = section.musicShelfRenderer.title.runs[0].text - if (!goodSections.includes(sectionType)) continue - - section.musicShelfRenderer.contents.forEach((item) => { - try { - // ! TEMPORARY I need to rework all my parsers to be able to handle edge cases - parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) - } catch { - return - } - }) - } - - return this.scrapedToMediaItems(parsedSearchResults) - } catch (error) { - console.log(error) - console.log(JSON.stringify(contents)) - throw Error('Something fucked up') - } + // const searchResulsts = (await this.requestManager.innerTubeFetch({ type: 'search', searchTerm, filter }).then((response) => response.json())) as InnerTube.SearchResponse } // TODO: Figure out why this still breaks sometimes (Figured out one cause: "Episodes" can appear as videos) public async getRecommendations() { - const homeResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse + console.time() + await this.getAlbumItems('MPREb_zu9EUJqrg8V').then((songs) => console.log(JSON.stringify(songs))) + console.timeEnd() - const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents + return [] // ! Need to completely rework this method - try { - const scrapedRecommendations: (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[] = [] - const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library', 'Recommended music videos', 'Recommended albums'] - for (const section of contents) { - const sectionType = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text - if (!goodSections.includes(sectionType)) continue - - const parsedContent = section.musicCarouselShelfRenderer.contents.map((content) => - 'musicTwoRowItemRenderer' in content ? parseTwoRowItemRenderer(content.musicTwoRowItemRenderer) : parseResponsiveListItemRenderer(content.musicResponsiveListItemRenderer), - ) - scrapedRecommendations.push(...parsedContent) - } - - return this.scrapedToMediaItems(scrapedRecommendations) - } catch (error) { - console.log(error) - console.log(JSON.stringify(contents)) - throw Error('Something fucked up') - } + // const homeResponse = (await this.requestManager.innerTubeFetch({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse } + // TODO: Move to innerTubeFetch method public async getAudioStream(id: string, headers: Headers) { - if (!/^[a-zA-Z0-9-_]{11}$/.test(id)) throw TypeError('Invalid youtube video Id') + if (!isValidVideoId(id)) throw TypeError('Invalid youtube video Id') // ? In the future, may want to implement the TVHTML5_SIMPLY_EMBEDDED_PLAYER client method both in order to bypass age-restrictions and just to serve as a fallback // ? However this has the downsides of being slower and (I think) requiring the user's cookies if the video is premium exclusive. @@ -223,25 +162,36 @@ export class YouTubeMusic implements Connection { * @param id The browseId of the album */ public async getAlbum(id: string): Promise { - const albumResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse + const albumResponse = await this.requestManager + .innerTubeFetch('/browse', { body: { browseId: id } }) + .then((response) => response.json() as Promise) + .catch(() => null) - const header = albumResponse.header.musicDetailHeaderRenderer + 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 connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection'] const name = header.title.runs[0].text, - thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails) + thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) - let artists: Album['artists'] = [] - for (const run of header.subtitle.runs) { - if (run.text === 'Various Artists') { - artists = 'Various Artists' - break + const artistMap = new Map() + 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 }) } + }) - if (run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ARTIST') { - artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }) - } - } + const artists: Album['artists'] = artistMap.size > 0 ? Array.from(artistMap, (artist) => ({ id: artist[0], name: artist[1].name, profilePicture: artist[1].profilePicture })) : 'Various Artists' const releaseYear = header.subtitle.runs.at(-1)?.text! @@ -252,26 +202,50 @@ export class YouTubeMusic implements Connection { * @param id The browseId of the album */ public async getAlbumItems(id: string): Promise { - const albumResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse + const albumResponse = await this.requestManager + .innerTubeFetch('/browse', { body: { browseId: id } }) + .then((response) => response.json() as Promise) + .catch(() => null) - const header = albumResponse.header.musicDetailHeaderRenderer + if (!albumResponse) throw Error(`Failed to fetch album ${id} of connection ${this.id}`) - const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents - let continuation = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.continuations?.[0].nextContinuationData.continuation + if ('error' in albumResponse) { + if (albumResponse.error.status === 'NOT_FOUND' || albumResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube album id') + + const errorMessage = `Unknown playlist response error: ${albumResponse.error.message}` + console.error(errorMessage) + throw Error(errorMessage) + } + + const header = albumResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer + + const contents = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicShelfRenderer.contents + 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.croppedSquareThumbnailRenderer.thumbnail.thumbnails) + const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) const album: Song['album'] = { id, name: header.title.runs[0].text } - const albumArtists = header.subtitle.runs - .filter((run) => run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ARTIST') - .map((run) => ({ id: run.navigationEndpoint!.browseEndpoint.browseId, name: run.text })) + const artistMap = new Map() + 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.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Album.ContinuationResponse + const continuationResponse = await this.requestManager + .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) + .then((response) => response.json() as Promise) + .catch(() => null) - contents.push(...continuationResponse.continuationContents.musicShelfContinuation.contents) - continuation = continuationResponse.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation + if (!continuationResponse) throw Error(`Failed to fetch album ${id} of connection ${this.id}`) + + 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 @@ -294,8 +268,8 @@ export class YouTubeMusic implements Connection { 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() - videoSchemas.forEach((video) => videoChannelMap.set(video.id!, video.snippet?.channelId!)) + const videoChannelMap = new Map() + 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 @@ -308,13 +282,21 @@ 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 + 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]) + } + }) + } - return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo } + const uploader: Song['uploader'] = artists ? undefined : videoChannelMap.get(id)! + + return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, uploader, isVideo } }) } @@ -323,8 +305,8 @@ export class YouTubeMusic implements Connection { */ public async getPlaylist(id: string): Promise { const playlistResponse = await this.requestManager - .ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }) - .then((response) => response.json() as Promise) + .innerTubeFetch('/browse', { body: { browseId: 'VL'.concat(id) } }) + .then((response) => response.json() as Promise) .catch(() => null) if (!playlistResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`) @@ -338,19 +320,23 @@ export class YouTubeMusic implements Connection { } const header = - 'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.header - ? playlistResponse.header.musicEditablePlaylistDetailHeaderRenderer.header.musicDetailHeaderRenderer - : playlistResponse.header.musicDetailHeaderRenderer + 'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0] + ? playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicEditablePlaylistDetailHeaderRenderer.header.musicResponsiveHeaderRenderer + : playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection'] const name = header.title.runs[0].text - const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails) + const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) - let createdBy: Playlist['createdBy'] - header.subtitle.runs.forEach((run) => { - if (run.navigationEndpoint && run.navigationEndpoint.browseEndpoint.browseId !== this.youtubeUserId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } - }) + const createdBy: Playlist['createdBy'] = + header.straplineTextOne.runs[0].navigationEndpoint?.browseEndpoint.browseId !== undefined + ? { + id: header.straplineTextOne.runs[0].navigationEndpoint.browseEndpoint.browseId, + name: header.straplineTextOne.runs[0].text, + profilePicture: header.straplineThumbnail ? extractLargestThumbnailUrl(header.straplineThumbnail.musicThumbnailRenderer.thumbnail.thumbnails) : undefined, + } + : undefined return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist } @@ -365,8 +351,8 @@ export class YouTubeMusic implements Connection { limit = options?.limit const playlistResponse = await this.requestManager - .ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }) - .then((response) => response.json() as Promise) + .innerTubeFetch('/browse', { body: { browseId: 'VL'.concat(id) } }) + .then((response) => response.json() as Promise) .catch(() => null) if (!playlistResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`) @@ -379,15 +365,20 @@ export class YouTubeMusic implements Connection { throw Error(errorMessage) } - const playableContents = playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents.filter( + const playableContents = playlistResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents.filter( (item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined, ) - let continuation = - playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation + let continuation = playlistResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation while (continuation && (!limit || playableContents.length < (startIndex ?? 0) + limit)) { - const continuationResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Playlist.ContinuationResponse + const continuationResponse = await this.requestManager + .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) + .then((response) => response.json() as Promise) + .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, ) @@ -451,281 +442,63 @@ export class YouTubeMusic implements Connection { }) } - private async scrapedToMediaItems(scrapedItems: T): Promise[]> { - const songIds = new Set(), - albumIds = new Set() + /** + * @param ids An array of youtube video ids. + * @throws Error if the fetch failed. TypeError if an invalid videoId was included in the request. + */ + // ? 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 { + if (ids.some((id) => !isValidVideoId(id))) throw TypeError('Invalid video id in request') - scrapedItems.forEach((item) => { - switch (item.type) { - case 'song': - 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 - } + const response = await this.requestManager + .innerTubeFetch('/queue', { body: { videoIds: ids } }) + .then((response) => response.json() as Promise) + .catch(() => null) + + if (!response) throw Error(`Failed to fetch ${ids.length} songs from connection ${this.id}`) + + if ('error' in response) { + if (response.error.status === 'NOT_FOUND') throw TypeError('Invalid video id in request') + + const errorMessage = `Unknown playlist items response error: ${response.error.message}` + console.error(errorMessage, response.error.status, response.error.code) + throw Error(errorMessage) + } + + 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 + 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 artists: Song['artists'] = [] + let album: Song['album'] + let uploader: Song['uploader'] + itemData.longBylineText.runs.forEach((run) => { + if (!run.navigationEndpoint) return + + const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + const runDetails = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } + if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') { + album = runDetails + } else if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') { + artists.push(runDetails) + } else { + uploader = runDetails + } + }) + + const isVideo = itemData.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 }) - - const songIdArray = Array.from(songIds) - const dividedIds: string[][] = [] - for (let i = 0; i < songIdArray.length; i += 50) dividedIds.push(songIdArray.slice(i, i + 50)) - - const access_token = await this.requestManager.accessToken - - const getSongDetails = () => - Promise.all(dividedIds.map((idsChunk) => ytDataApi.videos.list({ part: ['snippet', 'contentDetails'], id: idsChunk, access_token }))).then((responses) => - responses.map((response) => response.data.items!).flat(), - ) - // Oh FFS. Despite nothing documenting it ^this api can only query a maximum of 50 ids at a time. Addtionally, if you exceed that limit, it doesn't even give you the correct error, it says some nonsense about an invalid filter paramerter. FML. - const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id))) - - const [songDetails, albumDetails] = await Promise.all([getSongDetails(), getAlbumDetails()]) - const songDetailsMap = new Map(), - albumDetailsMap = new Map() - - songDetails.forEach((item) => songDetailsMap.set(item.id!, item)) - albumDetails.forEach((album) => albumDetailsMap.set(album.id, album)) - - const connection = { id: this.id, type: 'youtube-music' } satisfies (Song | Album | Artist | Playlist)['connection'] - - return scrapedItems.map((item) => { - switch (item.type) { - case 'song': - const { id, name, artists, isVideo, uploader } = item - const songDetails = songDetailsMap.get(id)! - const duration = secondsFromISO8601(songDetails.contentDetails?.duration!) - - 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 songAlbum = item.album?.id ? albumDetailsMap.get(item.album.id)! : undefined - const album = songAlbum ? { id: songAlbum.id, name: songAlbum.name } : undefined - - 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': - 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 Object.assign(item, { connection }) satisfies Artist - case '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[] - } - - // ! HOLY FUCK HOLY FUCK THIS IS IT!!!! THIS IS HOW YOU CAN BATCH FETCH FULL DETAILS FOR COMPLETELY UNRELATED SONGS IN ONE API CALL!!!! - // ! IT GIVES BACK FUCKING EVERYTHING (almost)! NAME, ALBUM, ARTISTS, UPLOADER, DURATION, THUMBNAIL. - // ! The only thing kinda missing is release date, but that could be fetched from the official API. In fact I'll already need to make a call to - // ! the offical API to get the thumbnails for the videos any way. And since you can batch call that one, you won't be making any extra queries just - // ! to get the release date. HOLY FUCK THIS IS PERFECT! (something is going to go wrong in the future for sure) - private async testMethod(videoIds: string[]) { - 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 response = await fetch('https://music.youtube.com/youtubei/v1/music/get_queue', { - headers: { - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0', - authorization: `Bearer ${await this.requestManager.accessToken}`, - }, - method: 'POST', - body: JSON.stringify({ - context: { - client: { - clientName: 'WEB_REMIX', - clientVersion: `1.${year + month + day}.01.00`, - }, - }, - videoIds, - }), - }) - - if (!response.ok) { - console.log(response) - return - } - - const data = await response.json() - console.log(JSON.stringify(data)) - } -} - -function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer): InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist { - const name = rowContent.title.runs[0].text - - let artists: InnerTube.ScrapedSong['artists'] | InnerTube.ScrapedAlbum['artists'] = [], - creator: InnerTube.ScrapedSong['uploader'] | InnerTube.ScrapedPlaylist['createdBy'] - - rowContent.subtitle.runs.forEach((run) => { - if (run.text === 'Various Artists') return (artists = 'Various Artists') - if (!run.navigationEndpoint) return - - const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType, - id = run.navigationEndpoint.browseEndpoint.browseId, - name = run.text - - switch (pageType) { - case 'MUSIC_PAGE_TYPE_ARTIST': - if (artists instanceof Array) artists.push({ id, name }) - break - case 'MUSIC_PAGE_TYPE_USER_CHANNEL': - creator = { id, name } - break - } - }) - - 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 : extractLargestThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails) - - let albumId: string | undefined - rowContent.menu?.menuRenderer.items.forEach((menuOption) => { - if ( - 'menuNavigationItemRenderer' in menuOption && - 'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint && - menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM' - ) - albumId = menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId - }) - - const album: InnerTube.ScrapedSong['album'] = albumId ? { id: albumId } : undefined - - return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.ScrapedSong - } - - 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': - return { id, name, type: 'album', artists, thumbnailUrl: image } satisfies InnerTube.ScrapedAlbum - case 'MUSIC_PAGE_TYPE_ARTIST': - case 'MUSIC_PAGE_TYPE_USER_CHANNEL': - return { id, name, type: 'artist', profilePicture: image } satisfies InnerTube.ScrapedArtist - case 'MUSIC_PAGE_TYPE_PLAYLIST': - return { id: id.slice(2), name, type: 'playlist', thumbnailUrl: image, createdBy: creator! } satisfies InnerTube.ScrapedPlaylist - default: - throw Error('Unexpected twoRowItem type: ' + pageType) - } -} - -function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveListItemRenderer): InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist { - const name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text - const column1Runs = listContent.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs - - let artists: InnerTube.ScrapedSong['artists'] | InnerTube.ScrapedAlbum['artists'] = [], - creator: InnerTube.ScrapedSong['uploader'] | InnerTube.ScrapedPlaylist['createdBy'] - - column1Runs.forEach((run) => { - if (run.text === 'Various Artists') return (artists = 'Various Artists') - if (!run.navigationEndpoint) return - - const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType, - id = run.navigationEndpoint.browseEndpoint.browseId, - name = run.text - - switch (pageType) { - case 'MUSIC_PAGE_TYPE_ARTIST': - if (artists instanceof Array) artists.push({ id, name }) - break - case 'MUSIC_PAGE_TYPE_USER_CHANNEL': - creator = { id, name } - break - } - }) - - if (!('navigationEndpoint' in listContent)) { - const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint?.videoId - if (!id) throw TypeError('Encountered a bad responsiveListItemRenderer, potentially and "Episode or something like that"') // ! I need to rework all my parsers to be able to handle these kinds of edge cases - - const isVideo = - listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== - 'MUSIC_VIDEO_TYPE_ATV' - const thumbnailUrl = isVideo ? undefined : extractLargestThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) - - const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0] - const album = - column2run?.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM' - ? { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text } - : undefined - - return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.ScrapedSong - } - - 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': - return { id, name, type: 'album', thumbnailUrl: image, artists } satisfies InnerTube.ScrapedAlbum - case 'MUSIC_PAGE_TYPE_ARTIST': - case 'MUSIC_PAGE_TYPE_USER_CHANNEL': - return { id, name, type: 'artist', profilePicture: image } satisfies InnerTube.ScrapedArtist - case 'MUSIC_PAGE_TYPE_PLAYLIST': - return { id: id.slice(2), name, type: 'playlist', thumbnailUrl: image, createdBy: creator! } satisfies InnerTube.ScrapedPlaylist - default: - throw Error('Unexpected responsiveListItem type: ' + pageType) - } -} - -function parseMusicCardShelfRenderer(cardContent: InnerTube.musicCardShelfRenderer): InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist { - const name = cardContent.title.runs[0].text - - let album: Song['album'], - artists: InnerTube.ScrapedSong['artists'] | InnerTube.ScrapedAlbum['artists'] = [], - creator: Song['uploader'] | Playlist['createdBy'] - - for (const run of cardContent.subtitle.runs) { - if (!run.navigationEndpoint) continue - - 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.push(runData) - break - case 'MUSIC_PAGE_TYPE_USER_CHANNEL': - creator = runData - break - } - } - - const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint - if ('watchEndpoint' in navigationEndpoint) { - const id = navigationEndpoint.watchEndpoint.videoId - const isVideo = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV' - 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': - return { id, name, type: 'album', thumbnailUrl: image, artists } satisfies InnerTube.ScrapedAlbum - case 'MUSIC_PAGE_TYPE_ARTIST': - case 'MUSIC_PAGE_TYPE_USER_CHANNEL': - return { id, name, type: 'artist', profilePicture: image } satisfies InnerTube.ScrapedArtist - case 'MUSIC_PAGE_TYPE_PLAYLIST': - return { id: id.slice(2), name, type: 'playlist', thumbnailUrl: image, createdBy: creator! } satisfies InnerTube.ScrapedPlaylist - default: - throw Error('Unexpected musicCardShelf type: ' + pageType) } } @@ -783,7 +556,7 @@ class YTRequestManager { this.accessTokenRefreshRequest = refreshAccessToken() .then(async ({ accessToken, expiry }) => { - await DB.connections.where('id', this.connectionId).update('tokens', { accessToken, refreshToken: this.refreshToken, expiry }) + await DB.connections.where('id', this.connectionId).update({ accessToken, expiry }) this.currentAccessToken = accessToken this.expiry = expiry this.accessTokenRefreshRequest = null @@ -797,7 +570,9 @@ class YTRequestManager { return this.accessTokenRefreshRequest } - public async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) { + public async innerTubeFetch(relativeRefrence: string, options?: { body?: Record }) { + 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}`, @@ -815,32 +590,7 @@ class YTRequestManager { }, } - let url: string - let body: Record - - 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 - } + const body = Object.assign({ context }, options?.body) return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) }) } @@ -858,14 +608,14 @@ class YTLibaryManager { } public async albums(): Promise { - const albumData = await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_liked_albums' }).then((response) => response.json() as Promise) + const albumData = await this.requestManager.innerTubeFetch('/browse', { body: { browseId: 'FEmusic_liked_albums' } }).then((response) => response.json() as Promise) 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 }) + .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) .then((response) => response.json() as Promise) items.push(...continuationData.continuationContents.gridContinuation.items) @@ -892,7 +642,7 @@ class YTLibaryManager { public async artists(): Promise { const artistsData = await this.requestManager - .ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_library_corpus_track_artists' }) + .innerTubeFetch('/browse', { body: { browseId: 'FEmusic_library_corpus_track_artists' } }) .then((response) => response.json() as Promise) const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer @@ -900,7 +650,7 @@ class YTLibaryManager { while (continuation) { const continuationData = await this.requestManager - .ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }) + .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) .then((response) => response.json() as Promise) contents.push(...continuationData.continuationContents.musicShelfContinuation.contents) @@ -918,14 +668,14 @@ class YTLibaryManager { } public async playlists(): Promise { - const playlistData = await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_liked_playlists' }).then((response) => response.json() as Promise) + const playlistData = await this.requestManager.innerTubeFetch('/browse', { body: { browseId: 'FEmusic_liked_playlists' } }).then((response) => response.json() as Promise) 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 }) + .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) .then((response) => response.json() as Promise) items.push(...continuationData.continuationContents.gridContinuation.items) @@ -1007,49 +757,15 @@ function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: numb * @param timestamp A string in the format Hours:Minutes:Seconds (Standard Timestamp format on YouTube) * @returns The total duration of that timestamp in seconds */ -const timestampToSeconds = (timestamp: string) => - timestamp +function timestampToSeconds(timestamp: string): number { + return timestamp .split(':') .reverse() .reduce((accumulator, current, index) => (accumulator += Number(current) * 60 ** index), 0) +} -// * This method is designed to parse the cookies returned from a yt response in the Set-Cookie headers. -// * Keeping it here in case I ever need to implement management of a user's youtube cookies -function parseAndSetCookies(response: Response) { - const setCookieHeaders = response.headers.getSetCookie().map((header) => { - const keyValueStrings = header.split('; ') - const [name, value] = keyValueStrings[0].split('=') - const result: Record = { name, value } - keyValueStrings.slice(1).forEach((string) => { - const [key, value] = string.split('=') - switch (key.toLowerCase()) { - case 'domain': - result.domain = value - break - case 'max-age': - result.expirationDate = Date.now() / 1000 + Number(value) - break - case 'expires': - result.expirationDate = result.expirationDate ? new Date(value).getTime() / 1000 : result.expirationDate // Max-Age takes precedence - break - case 'path': - result.path = value - break - case 'secure': - result.secure = true - break - case 'httponly': - result.httpOnly = true - break - case 'samesite': - const lowercaseValue = value.toLowerCase() - result.sameSite = lowercaseValue === 'none' ? 'no_restriction' : lowercaseValue - break - } - }) - console.log(JSON.stringify(result)) - return result - }) +function isValidVideoId(id: string): boolean { + return /^[a-zA-Z0-9-_]{11}$/.test(id) } // ? Helpfull Docummentation: @@ -1060,3 +776,10 @@ function parseAndSetCookies(response: Response) { // ? - DJ Sharpnel Blue Army full ver: iyL0zueK4CY (Standard video; 144p, 240p) // ? - HELLOHELL: p0qace56glE (Music video type ATV; Premium Exclusive) // ? - The Stampy Channel - Endless Episodes - 🔴 Rebroadcast: S8s3eRBPCX0 (Live stream; 144p, 240p, 360p, 480p, 720p, 1080p) + +// * Thoughs about how to handle VIDEO: +// The isVideo property of Song Objects pertains to whether that specific song entity is a video or auto-generated song. +// It says nothing about whehter or not that song has a video or auto-generated counterpart. Because in many situations +// it is not possible to identify if a scraped song even has a video or auto-generated counterpart, I think it is not a good +// approach to try to store that information in the song object. I need to find a simple way to identify which versions a +// song has though. Ideally that information is known before the song gets played. diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 72a7cc1..3b7dc4d 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -3,6 +3,7 @@ import { fail, redirect } from '@sveltejs/kit' import { compare, hash } from 'bcrypt-ts' import type { PageServerLoad, Actions } from './$types' import { DB } from '$lib/server/db' +import { SqliteError } from 'better-sqlite3' import jwt from 'jsonwebtoken' export const load: PageServerLoad = async ({ url }) => { @@ -37,9 +38,9 @@ export const actions: Actions = { const newUser = await DB.users .insert({ id: DB.uuid(), username: username.toString(), passwordHash }, '*') .then((data) => data[0]) - .catch((error: InstanceType) => error) + .catch((error: InstanceType) => error) - if (newUser instanceof DB.sqliteError) { + if (newUser instanceof SqliteError) { switch (newUser.code) { case 'SQLITE_CONSTRAINT_UNIQUE': return fail(400, { message: 'Username already in use' })