From 952c8383f99f3dcf1c2955986ca2aba9cd435095 Mon Sep 17 00:00:00 2001 From: Eclypsed Date: Wed, 3 Apr 2024 23:28:38 -0400 Subject: [PATCH] Redoing some of the types --- src/app.d.ts | 66 +---- src/lib/components/media/mediaCard.svelte | 2 +- src/lib/server/connections.ts | 12 +- src/lib/server/db.ts | 50 ++-- src/lib/server/jellyfin.ts | 36 +-- src/lib/server/youtube-music.ts | 230 ++++++++++-------- src/routes/(app)/user/+page.server.ts | 16 +- src/routes/(app)/user/+page.svelte | 15 +- .../(app)/user/connectionProfile.svelte | 14 +- src/routes/api/connections/+server.ts | 2 +- src/routes/api/search/+server.ts | 2 +- .../api/users/[userId]/connections/+server.ts | 2 +- .../users/[userId]/recommendations/+server.ts | 2 +- 13 files changed, 220 insertions(+), 229 deletions(-) diff --git a/src/app.d.ts b/src/app.d.ts index cb5aef2..6d684f0 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -24,22 +24,6 @@ declare global { passwordHash: string } - type ConnectionInfo = { - id: string - userId: string - } & ( - | { - type: 'jellyfin' - serviceInfo: Jellyfin.SerivceInfo - tokens: Jellyfin.Tokens - } - | { - type: 'youtube-music' - serviceInfo: YouTubeMusic.SerivceInfo - tokens: YouTubeMusic.Tokens - } - ) - interface Connection { public id: string getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]> @@ -51,10 +35,7 @@ declare global { // Big data should be fetched as needed in the app, these exist to ensure that the info necessary to fetch that data is there. type Song = { - connection: { - id: string - type: 'jellyfin' | 'youtube-music' - } + connection: string id: string name: string type: 'song' @@ -74,10 +55,7 @@ declare global { } type Album = { - connection: { - id: string - type: 'jellyfin' | 'youtube-music' - } + connection: string id: string name: string type: 'album' @@ -93,10 +71,7 @@ declare global { // Need to figure out how to do Artists, maybe just query MusicBrainz? type Artist = { - connection: { - id: string - type: 'jellyfin' | 'youtube-music' - } + connection: string id: string name: string type: 'artist' @@ -104,46 +79,13 @@ declare global { } type Playlist = { - connection: { - id: string - type: 'jellyfin' | 'youtube-music' - } + connection: string id: string name: string type: 'playlist' thumbnail?: string description?: string } - - namespace Jellyfin { - // The jellyfin API will not always return the data it says it will, for example /Users/AuthenticateByName says it will - // retrun the ServerName, it wont. This must be fetched from /System/Info. - // So, ONLY DEFINE THE INTERFACES FOR DATA THAT IS GARUNTEED TO BE RETURNED (unless the data value itself is inherently optional) - type SerivceInfo = { - userId: string - urlOrigin: string - username?: string - serverName?: string - } - - type Tokens = { - accessToken: string - } - } - - namespace YouTubeMusic { - type SerivceInfo = { - userId: string - username?: string - profilePicture?: string - } - - type Tokens = { - accessToken: string - refreshToken: string - expiry: number - } - } } export {} diff --git a/src/lib/components/media/mediaCard.svelte b/src/lib/components/media/mediaCard.svelte index a3006c0..0ca8a72 100644 --- a/src/lib/components/media/mediaCard.svelte +++ b/src/lib/components/media/mediaCard.svelte @@ -28,7 +28,7 @@ {#if 'artists' in mediaItem && mediaItem.artists} {#each mediaItem.artists as artist} {@const listIndex = mediaItem.artists.indexOf(artist)} - {artist.name} + {artist.name} {#if listIndex < mediaItem.artists.length - 1} , {/if} diff --git a/src/lib/server/connections.ts b/src/lib/server/connections.ts index cf1690e..ca12eb6 100644 --- a/src/lib/server/connections.ts +++ b/src/lib/server/connections.ts @@ -1,14 +1,16 @@ import { DB, type DBConnectionInfo } from './db' -import { Jellyfin } from './jellyfin' -import { YouTubeMusic } from './youtube-music' +import { Jellyfin, type JellyfinConnectionInfo } from './jellyfin' +import { YouTubeMusic, type YouTubeMusicConnectionInfo } from './youtube-music' + +export type ConnectionInfo = JellyfinConnectionInfo | YouTubeMusicConnectionInfo const constructConnection = (connectionInfo: DBConnectionInfo): Connection => { - const { id, userId, type, serviceInfo, tokens } = connectionInfo + const { id, userId, type, service, tokens } = connectionInfo switch (type) { case 'jellyfin': - return new Jellyfin(id, userId, serviceInfo.userId, serviceInfo.urlOrigin, tokens.accessToken) + return new Jellyfin(id, userId, service.userId, service.urlOrigin, tokens.accessToken) case 'youtube-music': - return new YouTubeMusic(id, userId, serviceInfo.userId, tokens) + return new YouTubeMusic(id, userId, service.userId, tokens) } } diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 1c8872b..20131e5 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -10,22 +10,34 @@ interface DBConnectionsTableSchema { tokens?: string } -type DBServiceInfo = - | { - type: 'jellyfin' - serviceInfo: Pick - tokens: Jellyfin.Tokens - } - | { - type: 'youtube-music' - serviceInfo: Pick - tokens: YouTubeMusic.Tokens - } - -export type DBConnectionInfo = { +type JellyfinDBConnection = { id: string userId: string -} & DBServiceInfo + type: 'jellyfin' + service: { + userId: string + urlOrigin: string + } + tokens: { + accessToken: string + } +} + +type YouTubeMusicDBConnection = { + id: string + userId: string + type: 'youtube-music' + service: { + userId: string + } + tokens: { + accessToken: string + refreshToken: string + expiry: number + } +} + +export type DBConnectionInfo = JellyfinDBConnection | YouTubeMusicDBConnection class Storage { private readonly database: Sqlite3DB @@ -86,7 +98,7 @@ class Storage { const { userId, type, service, tokens } = result const parsedService = service ? JSON.parse(service) : undefined const parsedTokens = tokens ? JSON.parse(tokens) : undefined - connectionInfo.push({ id, userId, type: type as DBServiceInfo['type'], serviceInfo: parsedService, tokens: parsedTokens }) + connectionInfo.push({ id, userId, type: type as DBConnectionInfo['type'], service: parsedService, tokens: parsedTokens }) } return connectionInfo } @@ -97,15 +109,15 @@ class Storage { for (const { id, type, service, tokens } of connectionRows) { const parsedService = service ? JSON.parse(service) : undefined const parsedTokens = tokens ? JSON.parse(tokens) : undefined - connections.push({ id, userId, type: type as DBServiceInfo['type'], serviceInfo: parsedService, tokens: parsedTokens }) + connections.push({ id, userId, type: type as DBConnectionInfo['type'], service: parsedService, tokens: parsedTokens }) } return connections } - public addConnectionInfo = (userId: string, serviceData: DBServiceInfo): string => { - const { type, serviceInfo, tokens } = serviceData + public addConnectionInfo = (connectionInfo: Omit): string => { + const { userId, type, service, tokens } = connectionInfo const connectionId = generateUUID() - this.database.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(serviceInfo), JSON.stringify(tokens)) + this.database.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens)) return connectionId } diff --git a/src/lib/server/jellyfin.ts b/src/lib/server/jellyfin.ts index 7f6347b..10dfe9a 100644 --- a/src/lib/server/jellyfin.ts +++ b/src/lib/server/jellyfin.ts @@ -1,3 +1,18 @@ +export type JellyfinConnectionInfo = { + id: string + userId: string + type: 'jellyfin' + service: { + userId: string + serverUrl: string + username: string + serverName: string + } + tokens: { + accessToken: string + } +} + export class Jellyfin implements Connection { public id: string private userId: string @@ -26,7 +41,7 @@ export class Jellyfin implements Connection { // userId: this.jfUserId, // }) - public getConnectionInfo = async (): Promise> => { + public getConnectionInfo = async (): Promise => { const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl).href const systemUrl = new URL('System/Info', this.serverUrl).href @@ -40,9 +55,9 @@ export class Jellyfin implements Connection { id: this.id, userId: this.userId, type: 'jellyfin', - serviceInfo: { + service: { userId: this.jfUserId, - urlOrigin: this.serverUrl, + serverUrl: this.serverUrl, username: userData.Name, serverName: systemData.ServerName, }, @@ -110,10 +125,7 @@ export class Jellyfin implements Connection { const album: Song['album'] = song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined return { - connection: { - id: this.id, - type: 'jellyfin', - }, + connection: this.id, type: 'song', id: song.Id, name: song.Name, @@ -135,10 +147,7 @@ export class Jellyfin implements Connection { : undefined return { - connection: { - id: this.id, - type: 'jellyfin', - }, + connection: this.id, type: 'album', id: album.Id, name: album.Name, @@ -153,10 +162,7 @@ export class Jellyfin implements Connection { const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, this.serverUrl).toString() : undefined return { - connection: { - id: this.id, - type: 'jellyfin', - }, + connection: this.id, id: playlist.Id, name: playlist.Name, type: 'playlist', diff --git a/src/lib/server/youtube-music.ts b/src/lib/server/youtube-music.ts index 6246e1d..5379ddc 100644 --- a/src/lib/server/youtube-music.ts +++ b/src/lib/server/youtube-music.ts @@ -3,13 +3,29 @@ import { DB } from './db' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' +export type YouTubeMusicConnectionInfo = { + id: string + userId: string + type: 'youtube-music' + service: { + userId: string + username: string + profilePicture: string + } + tokens: { + accessToken: string + refreshToken: string + expiry: number + } +} + export class YouTubeMusic implements Connection { public id: string private userId: string private ytUserId: string - private tokens: YouTubeMusic.Tokens + private tokens: YouTubeMusicConnectionInfo['tokens'] - constructor(id: string, userId: string, youtubeUserId: string, tokens: YouTubeMusic.Tokens) { + constructor(id: string, userId: string, youtubeUserId: string, tokens: YouTubeMusicConnectionInfo['tokens']) { this.id = id this.userId = userId this.ytUserId = youtubeUserId @@ -30,7 +46,7 @@ export class YouTubeMusic implements Connection { }) } - private getTokens = async (): Promise => { + private getTokens = async (): Promise => { if (this.tokens.expiry < Date.now()) { const refreshToken = this.tokens.refreshToken @@ -47,7 +63,7 @@ export class YouTubeMusic implements Connection { const { access_token, expires_in } = await response.json() const newExpiry = Date.now() + expires_in * 1000 - const newTokens: YouTubeMusic.Tokens = { accessToken: access_token, refreshToken, expiry: newExpiry } + const newTokens: YouTubeMusicConnectionInfo['tokens'] = { accessToken: access_token, refreshToken, expiry: newExpiry } DB.updateTokens(this.id, newTokens) this.tokens = newTokens } @@ -55,7 +71,7 @@ export class YouTubeMusic implements Connection { return this.tokens } - public getConnectionInfo = async (): Promise> => { + public getConnectionInfo = async (): Promise => { const youtube = google.youtube('v3') const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: (await this.getTokens()).accessToken }) const userChannel = userChannelResponse.data.items![0] @@ -64,10 +80,10 @@ export class YouTubeMusic implements Connection { id: this.id, userId: this.userId, type: 'youtube-music', - serviceInfo: { + service: { userId: this.ytUserId, username: userChannel.snippet?.title as string, - profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined, + profilePicture: userChannel.snippet?.thumbnails?.default?.url as string, }, tokens: await this.getTokens(), } @@ -104,16 +120,20 @@ export class YouTubeMusic implements Connection { const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents const parsedSearchResults: (Song | Album | Artist | Playlist)[] = [] + const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists'] for (const section of contents) { if ('musicCardShelfRenderer' in section) { - parsedSearchResults.push(this.parseMusicCardShelfRenderer(section.musicCardShelfRenderer)) + parsedSearchResults.push(parseMusicCardShelfRenderer(this.id, section.musicCardShelfRenderer)) + section.musicCardShelfRenderer.contents?.forEach((item) => { + parsedSearchResults.push(parseResponsiveListItemRenderer(this.id, item.musicResponsiveListItemRenderer)) + }) continue } const sectionType = section.musicShelfRenderer.title.runs[0].text - if (sectionType === 'Episodes' || sectionType === 'Podcasts' || sectionType === 'Profiles') continue + if (!goodSections.includes(sectionType)) continue - const parsedSectionContents = section.musicShelfRenderer.contents.map((item) => this.parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) + const parsedSectionContents = section.musicShelfRenderer.contents.map((item) => parseResponsiveListItemRenderer(this.id, item.musicResponsiveListItemRenderer)) parsedSearchResults.push(...parsedSectionContents) } @@ -139,120 +159,122 @@ export class YouTubeMusic implements Connection { const contents = browseResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents const recommendations: (Song | Album | Artist | Playlist)[] = [] + const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library'] for (const section of contents) { - const header = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text - if (header === 'New releases') continue // The 'New Releases Section is generally filled with music that is not tailored to the user' + 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 ? this.parseTwoRowItemRenderer(content.musicTwoRowItemRenderer) : this.parseResponsiveListItemRenderer(content.musicResponsiveListItemRenderer), + 'musicTwoRowItemRenderer' in content ? parseTwoRowItemRenderer(this.id, content.musicTwoRowItemRenderer) : parseResponsiveListItemRenderer(this.id, content.musicResponsiveListItemRenderer), ) recommendations.push(...parsedContent) } return recommendations } +} - private parseTwoRowItemRenderer = (rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist => { - const connection = { id: this.id, type: 'youtube-music' } satisfies (Song | Album | Artist | Playlist)['connection'] - const name = rowContent.title.runs[0].text +const parseTwoRowItemRenderer = (connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist => { + const name = rowContent.title.runs[0].text - let artists: (Song | Album)['artists'] - for (const run of rowContent.subtitle.runs) { - if (!run.navigationEndpoint) continue + let artists: (Song | Album)['artists'] + for (const run of rowContent.subtitle.runs) { + if (!run.navigationEndpoint) continue + const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } + artists ? artists.push(artist) : (artists = [artist]) + } + + const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url) + + if ('watchEndpoint' in rowContent.navigationEndpoint) { + const id = rowContent.navigationEndpoint.watchEndpoint.videoId + return { connection, id, name, type: 'song', artists, thumbnail } satisfies Song + } + + const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + const id = rowContent.navigationEndpoint.browseEndpoint.browseId + switch (pageType) { + case 'MUSIC_PAGE_TYPE_ALBUM': + return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album + case 'MUSIC_PAGE_TYPE_ARTIST': + return { connection, id, name, type: 'artist', thumbnail } satisfies Artist + case 'MUSIC_PAGE_TYPE_PLAYLIST': + return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist + } +} + +const parseResponsiveListItemRenderer = (connection: string, listContent: InnerTube.musicResponsiveListItemRenderer): Song | Album | Artist | Playlist => { + const name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text + const thumbnail = refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) + + let artists: (Song | Album)['artists'] + for (const run of listContent.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs) { + if (!run.navigationEndpoint) continue + const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } + artists ? artists.push(artist) : (artists = [artist]) + } + + if (!('navigationEndpoint' in listContent)) { + const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId + const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0] + let album: Song['album'] + if (column2run?.navigationEndpoint && column2run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM') { + album = { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text } + } + + return { connection, id, name, type: 'song', artists, album, thumbnail } satisfies Song + } + + const id = listContent.navigationEndpoint.browseEndpoint.browseId + const pageType = listContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + switch (pageType) { + case 'MUSIC_PAGE_TYPE_ALBUM': + return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album + case 'MUSIC_PAGE_TYPE_ARTIST': + return { connection, id, name, type: 'artist', thumbnail } satisfies Artist + case 'MUSIC_PAGE_TYPE_PLAYLIST': + return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist + } +} + +const parseMusicCardShelfRenderer = (connection: string, cardContent: InnerTube.musicCardShelfRenderer): Song | Album | Artist | Playlist => { + const name = cardContent.title.runs[0].text + const thumbnail = refineThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) + + let album: Song['album'], artists: (Song | Album)['artists'] + for (const run of cardContent.subtitle.runs) { + if (!run.navigationEndpoint) continue + + const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') { const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } artists ? artists.push(artist) : (artists = [artist]) - } - - const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url) - - if ('watchEndpoint' in rowContent.navigationEndpoint) { - const id = rowContent.navigationEndpoint.watchEndpoint.videoId - return { connection, id, name, type: 'song', artists, thumbnail } satisfies Song - } - - const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType - const id = rowContent.navigationEndpoint.browseEndpoint.browseId - switch (pageType) { - case 'MUSIC_PAGE_TYPE_ALBUM': - return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album - case 'MUSIC_PAGE_TYPE_ARTIST': - return { connection, id, name, type: 'artist', thumbnail } satisfies Artist - case 'MUSIC_PAGE_TYPE_PLAYLIST': - return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist + } else if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') { + album = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } } } - private parseResponsiveListItemRenderer = (listContent: InnerTube.musicResponsiveListItemRenderer): Song | Album | Artist | Playlist => { - const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection'] - const name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text - const thumbnail = refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) - - let artists: (Song | Album)['artists'] - for (const run of listContent.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs) { - if (!run.navigationEndpoint) continue - const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } - artists ? artists.push(artist) : (artists = [artist]) - } - - if (!('navigationEndpoint' in listContent)) { - const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId - const column2run = listContent.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text.runs?.[0] - const pageIsAlbum = column2run?.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM' - const album: Song['album'] = pageIsAlbum ? { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text } : undefined - - return { connection, id, name, type: 'song', artists, album, thumbnail } satisfies Song - } - - const pageType = listContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType - const id = listContent.navigationEndpoint.browseEndpoint.browseId - switch (pageType) { - case 'MUSIC_PAGE_TYPE_ALBUM': - return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album - case 'MUSIC_PAGE_TYPE_ARTIST': - return { connection, id, name, type: 'artist', thumbnail } satisfies Artist - case 'MUSIC_PAGE_TYPE_PLAYLIST': - return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist - } + const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint + if ('watchEndpoint' in navigationEndpoint) { + const id = navigationEndpoint.watchEndpoint.videoId + return { connection, id, name, type: 'song', artists, album, thumbnail } satisfies Song } - private parseMusicCardShelfRenderer = (cardContent: InnerTube.musicCardShelfRenderer): Song | Album | Artist | Playlist => { - const connection = { id: this.id, type: 'youtube-music' } satisfies (Song | Album | Artist | Playlist)['connection'] - const name = cardContent.title.runs[0].text - const thumbnail = refineThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) - - let album: Song['album'], artists: (Song | Album)['artists'] - for (const run of cardContent.subtitle.runs) { - if (!run.navigationEndpoint) continue - - const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType - if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') { - const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } - artists ? artists.push(artist) : (artists = [artist]) - } else if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') { - album = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } - } - } - - const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint - if ('watchEndpoint' in navigationEndpoint) { - const id = navigationEndpoint.watchEndpoint.videoId - return { connection, id, name, type: 'song', artists, album, thumbnail } satisfies Song - } - - const pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType - const id = navigationEndpoint.browseEndpoint.browseId - switch (pageType) { - case 'MUSIC_PAGE_TYPE_ALBUM': - return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album - case 'MUSIC_PAGE_TYPE_ARTIST': - return { connection, id, name, type: 'artist', thumbnail } satisfies Artist - case 'MUSIC_PAGE_TYPE_PLAYLIST': - return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist - } + const pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + const id = navigationEndpoint.browseEndpoint.browseId + switch (pageType) { + case 'MUSIC_PAGE_TYPE_ALBUM': + return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album + case 'MUSIC_PAGE_TYPE_ARTIST': + return { connection, id, name, type: 'artist', thumbnail } satisfies Artist + case 'MUSIC_PAGE_TYPE_PLAYLIST': + return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist } } const refineThumbnailUrl = (urlString: string): string => { + if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url') + const url = new URL(urlString) if (url.origin === 'https://i.ytimg.com') { return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault') @@ -452,14 +474,14 @@ declare namespace InnerTube { runs?: [ { text: string - navigationEndpoint: { + navigationEndpoint?: { browseEndpoint: browseEndpoint } }, ] } } - }, + }?, ] } | { diff --git a/src/routes/(app)/user/+page.server.ts b/src/routes/(app)/user/+page.server.ts index ac42d26..6ac27b4 100644 --- a/src/routes/(app)/user/+page.server.ts +++ b/src/routes/(app)/user/+page.server.ts @@ -4,17 +4,18 @@ import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import type { PageServerLoad, Actions } from './$types' import { DB } from '$lib/server/db' import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin' +import type { ConnectionInfo } from '$lib/server/connections' import { google } from 'googleapis' export const load: PageServerLoad = async ({ fetch, locals }) => { - const connectionsResponse = await fetch(`/api/users/${locals.user.id}/connections`, { + const connectionInfoResponse = await fetch(`/api/users/${locals.user.id}/connections`, { method: 'GET', headers: { apikey: SECRET_INTERNAL_API_KEY }, - }) + }).then((response) => response.json()) - const userConnections = await connectionsResponse.json() + const connections: ConnectionInfo[] = connectionInfoResponse.connections - return { connections: userConnections.connections } + return { connections } } export const actions: Actions = { @@ -28,7 +29,7 @@ export const actions: Actions = { if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message }) - const newConnectionId = DB.addConnectionInfo(locals.user.id, { type: 'jellyfin', serviceInfo: { userId: authData.User.Id, urlOrigin: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } }) + const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, urlOrigin: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } }) const response = await fetch(`/api/connections?ids=${newConnectionId}`, { method: 'GET', @@ -49,9 +50,10 @@ export const actions: Actions = { const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! }) const userChannel = userChannelResponse.data.items![0] - const newConnectionId = DB.addConnectionInfo(locals.user.id, { + const newConnectionId = DB.addConnectionInfo({ + userId: locals.user.id, type: 'youtube-music', - serviceInfo: { userId: userChannel.id! }, + service: { userId: userChannel.id! }, tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, }) diff --git a/src/routes/(app)/user/+page.svelte b/src/routes/(app)/user/+page.svelte index e431141..dcf2729 100644 --- a/src/routes/(app)/user/+page.svelte +++ b/src/routes/(app)/user/+page.svelte @@ -16,7 +16,7 @@ import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' export let data: PageServerData & LayoutData - let connections: ConnectionInfo[] = data.connections + let connections = data.connections const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => { const { serverUrl, username, password } = Object.fromEntries(formData) @@ -39,7 +39,7 @@ if (result.type === 'failure') { return ($newestAlert = ['warning', result.data?.message]) } else if (result.type === 'success') { - const newConnection: ConnectionInfo = result.data!.newConnection + const newConnection = result.data!.newConnection connections = [...connections, newConnection] newConnectionModal = null @@ -72,7 +72,7 @@ if (result.type === 'failure') { return ($newestAlert = ['warning', result.data?.message]) } else if (result.type === 'success') { - const newConnection: ConnectionInfo = result.data!.newConnection + const newConnection = result.data!.newConnection connections = [...connections, newConnection] return ($newestAlert = ['success', 'Added Youtube Music']) } @@ -135,7 +135,14 @@
{#each connections as connection} - + {/each}
{#if newConnectionModal !== null} diff --git a/src/routes/(app)/user/connectionProfile.svelte b/src/routes/(app)/user/connectionProfile.svelte index e5bb118..1a65738 100644 --- a/src/routes/(app)/user/connectionProfile.svelte +++ b/src/routes/(app)/user/connectionProfile.svelte @@ -6,24 +6,22 @@ import { fly } from 'svelte/transition' import { enhance } from '$app/forms' - export let connection: ConnectionInfo + export let id: string, type: 'jellyfin' | 'youtube-music', username: string | undefined, profilePicture: string | undefined, serverName: string | undefined export let submitFunction: SubmitFunction - $: serviceData = Services[connection.type] + $: serviceData = Services[type] let showModal = false - const subHeaderItems: string[] = [] - if ('username' in connection.serviceInfo && connection.serviceInfo.username) subHeaderItems.push(connection.serviceInfo.username) - if ('serverName' in connection.serviceInfo && connection.serviceInfo.serverName) subHeaderItems.push(connection.serviceInfo.serverName) + const subHeaderItems = [username, serverName]
{serviceData.displayName} icon - {#if 'profilePicture' in connection.serviceInfo && connection.serviceInfo.profilePicture} - + {#if profilePicture} + {/if}
@@ -42,7 +40,7 @@ Delete Connection - + {/if}
diff --git a/src/routes/api/connections/+server.ts b/src/routes/api/connections/+server.ts index 4693496..f95a662 100644 --- a/src/routes/api/connections/+server.ts +++ b/src/routes/api/connections/+server.ts @@ -1,5 +1,5 @@ import type { RequestHandler } from '@sveltejs/kit' -import { Connections } from '$lib/server/connections' +import { Connections, type ConnectionInfo } from '$lib/server/connections' export const GET: RequestHandler = async ({ url }) => { const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',') diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index 747c8af..7ae6135 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ url }) => { await connection .search(query) .then((results) => searchResults.push(...results)) - .catch((reason) => console.log(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)) + .catch((reason) => console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)) } return Response.json({ searchResults }) diff --git a/src/routes/api/users/[userId]/connections/+server.ts b/src/routes/api/users/[userId]/connections/+server.ts index 6ec20ca..3df1b33 100644 --- a/src/routes/api/users/[userId]/connections/+server.ts +++ b/src/routes/api/users/[userId]/connections/+server.ts @@ -1,5 +1,5 @@ import type { RequestHandler } from '@sveltejs/kit' -import { Connections } from '$lib/server/connections' +import { Connections, type ConnectionInfo } from '$lib/server/connections' export const GET: RequestHandler = async ({ params }) => { const userId = params.userId! diff --git a/src/routes/api/users/[userId]/recommendations/+server.ts b/src/routes/api/users/[userId]/recommendations/+server.ts index 600bd6b..52095ed 100644 --- a/src/routes/api/users/[userId]/recommendations/+server.ts +++ b/src/routes/api/users/[userId]/recommendations/+server.ts @@ -6,7 +6,7 @@ import { Connections } from '$lib/server/connections' export const GET: RequestHandler = async ({ params }) => { const userId = params.userId! - const recommendations: (Song | Album | Playlist)[] = [] + const recommendations: (Song | Album | Artist | Playlist)[] = [] for (const connection of Connections.getUserConnections(userId)) { await connection .getRecommendations()