Redoing some of the types

This commit is contained in:
Eclypsed
2024-04-03 23:28:38 -04:00
parent c01a7f6a03
commit 952c8383f9
13 changed files with 220 additions and 229 deletions

66
src/app.d.ts vendored
View File

@@ -24,22 +24,6 @@ declare global {
passwordHash: string 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 { interface Connection {
public id: string public id: string
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]> 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. // 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 = { type Song = {
connection: { connection: string
id: string
type: 'jellyfin' | 'youtube-music'
}
id: string id: string
name: string name: string
type: 'song' type: 'song'
@@ -74,10 +55,7 @@ declare global {
} }
type Album = { type Album = {
connection: { connection: string
id: string
type: 'jellyfin' | 'youtube-music'
}
id: string id: string
name: string name: string
type: 'album' type: 'album'
@@ -93,10 +71,7 @@ declare global {
// Need to figure out how to do Artists, maybe just query MusicBrainz? // Need to figure out how to do Artists, maybe just query MusicBrainz?
type Artist = { type Artist = {
connection: { connection: string
id: string
type: 'jellyfin' | 'youtube-music'
}
id: string id: string
name: string name: string
type: 'artist' type: 'artist'
@@ -104,46 +79,13 @@ declare global {
} }
type Playlist = { type Playlist = {
connection: { connection: string
id: string
type: 'jellyfin' | 'youtube-music'
}
id: string id: string
name: string name: string
type: 'playlist' type: 'playlist'
thumbnail?: string thumbnail?: string
description?: 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 {} export {}

View File

@@ -28,7 +28,7 @@
{#if 'artists' in mediaItem && mediaItem.artists} {#if 'artists' in mediaItem && mediaItem.artists}
{#each mediaItem.artists as artist} {#each mediaItem.artists as artist}
{@const listIndex = mediaItem.artists.indexOf(artist)} {@const listIndex = mediaItem.artists.indexOf(artist)}
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a> <a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection}">{artist.name}</a>
{#if listIndex < mediaItem.artists.length - 1} {#if listIndex < mediaItem.artists.length - 1}
<span class="mr-0.5 text-sm">,</span> <span class="mr-0.5 text-sm">,</span>
{/if} {/if}

View File

@@ -1,14 +1,16 @@
import { DB, type DBConnectionInfo } from './db' import { DB, type DBConnectionInfo } from './db'
import { Jellyfin } from './jellyfin' import { Jellyfin, type JellyfinConnectionInfo } from './jellyfin'
import { YouTubeMusic } from './youtube-music' import { YouTubeMusic, type YouTubeMusicConnectionInfo } from './youtube-music'
export type ConnectionInfo = JellyfinConnectionInfo | YouTubeMusicConnectionInfo
const constructConnection = (connectionInfo: DBConnectionInfo): Connection => { const constructConnection = (connectionInfo: DBConnectionInfo): Connection => {
const { id, userId, type, serviceInfo, tokens } = connectionInfo const { id, userId, type, service, tokens } = connectionInfo
switch (type) { switch (type) {
case 'jellyfin': 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': case 'youtube-music':
return new YouTubeMusic(id, userId, serviceInfo.userId, tokens) return new YouTubeMusic(id, userId, service.userId, tokens)
} }
} }

View File

@@ -10,22 +10,34 @@ interface DBConnectionsTableSchema {
tokens?: string tokens?: string
} }
type DBServiceInfo = type JellyfinDBConnection = {
| {
type: 'jellyfin'
serviceInfo: Pick<Jellyfin.SerivceInfo, 'userId' | 'urlOrigin'>
tokens: Jellyfin.Tokens
}
| {
type: 'youtube-music'
serviceInfo: Pick<YouTubeMusic.SerivceInfo, 'userId'>
tokens: YouTubeMusic.Tokens
}
export type DBConnectionInfo = {
id: string id: string
userId: 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 { class Storage {
private readonly database: Sqlite3DB private readonly database: Sqlite3DB
@@ -86,7 +98,7 @@ class Storage {
const { userId, type, service, tokens } = result const { userId, type, service, tokens } = result
const parsedService = service ? JSON.parse(service) : undefined const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : 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 return connectionInfo
} }
@@ -97,15 +109,15 @@ class Storage {
for (const { id, type, service, tokens } of connectionRows) { for (const { id, type, service, tokens } of connectionRows) {
const parsedService = service ? JSON.parse(service) : undefined const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : 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 return connections
} }
public addConnectionInfo = (userId: string, serviceData: DBServiceInfo): string => { public addConnectionInfo = (connectionInfo: Omit<DBConnectionInfo, 'id'>): string => {
const { type, serviceInfo, tokens } = serviceData const { userId, type, service, tokens } = connectionInfo
const connectionId = generateUUID() 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 return connectionId
} }

View File

@@ -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 { export class Jellyfin implements Connection {
public id: string public id: string
private userId: string private userId: string
@@ -26,7 +41,7 @@ export class Jellyfin implements Connection {
// userId: this.jfUserId, // userId: this.jfUserId,
// }) // })
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'jellyfin' }>> => { public getConnectionInfo = async (): Promise<JellyfinConnectionInfo> => {
const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl).href const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl).href
const systemUrl = new URL('System/Info', this.serverUrl).href const systemUrl = new URL('System/Info', this.serverUrl).href
@@ -40,9 +55,9 @@ export class Jellyfin implements Connection {
id: this.id, id: this.id,
userId: this.userId, userId: this.userId,
type: 'jellyfin', type: 'jellyfin',
serviceInfo: { service: {
userId: this.jfUserId, userId: this.jfUserId,
urlOrigin: this.serverUrl, serverUrl: this.serverUrl,
username: userData.Name, username: userData.Name,
serverName: systemData.ServerName, 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 const album: Song['album'] = song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined
return { return {
connection: { connection: this.id,
id: this.id,
type: 'jellyfin',
},
type: 'song', type: 'song',
id: song.Id, id: song.Id,
name: song.Name, name: song.Name,
@@ -135,10 +147,7 @@ export class Jellyfin implements Connection {
: undefined : undefined
return { return {
connection: { connection: this.id,
id: this.id,
type: 'jellyfin',
},
type: 'album', type: 'album',
id: album.Id, id: album.Id,
name: album.Name, 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 const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, this.serverUrl).toString() : undefined
return { return {
connection: { connection: this.id,
id: this.id,
type: 'jellyfin',
},
id: playlist.Id, id: playlist.Id,
name: playlist.Name, name: playlist.Name,
type: 'playlist', type: 'playlist',

View File

@@ -3,13 +3,29 @@ import { DB } from './db'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
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 { export class YouTubeMusic implements Connection {
public id: string public id: string
private userId: string private userId: string
private ytUserId: 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.id = id
this.userId = userId this.userId = userId
this.ytUserId = youtubeUserId this.ytUserId = youtubeUserId
@@ -30,7 +46,7 @@ export class YouTubeMusic implements Connection {
}) })
} }
private getTokens = async (): Promise<YouTubeMusic.Tokens> => { private getTokens = async (): Promise<YouTubeMusicConnectionInfo['tokens']> => {
if (this.tokens.expiry < Date.now()) { if (this.tokens.expiry < Date.now()) {
const refreshToken = this.tokens.refreshToken const refreshToken = this.tokens.refreshToken
@@ -47,7 +63,7 @@ export class YouTubeMusic implements Connection {
const { access_token, expires_in } = await response.json() const { access_token, expires_in } = await response.json()
const newExpiry = Date.now() + expires_in * 1000 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) DB.updateTokens(this.id, newTokens)
this.tokens = newTokens this.tokens = newTokens
} }
@@ -55,7 +71,7 @@ export class YouTubeMusic implements Connection {
return this.tokens return this.tokens
} }
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'youtube-music' }>> => { public getConnectionInfo = async (): Promise<YouTubeMusicConnectionInfo> => {
const youtube = google.youtube('v3') const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: (await this.getTokens()).accessToken }) const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: (await this.getTokens()).accessToken })
const userChannel = userChannelResponse.data.items![0] const userChannel = userChannelResponse.data.items![0]
@@ -64,10 +80,10 @@ export class YouTubeMusic implements Connection {
id: this.id, id: this.id,
userId: this.userId, userId: this.userId,
type: 'youtube-music', type: 'youtube-music',
serviceInfo: { service: {
userId: this.ytUserId, userId: this.ytUserId,
username: userChannel.snippet?.title as string, 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(), 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 contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
const parsedSearchResults: (Song | Album | Artist | Playlist)[] = [] const parsedSearchResults: (Song | Album | Artist | Playlist)[] = []
const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists']
for (const section of contents) { for (const section of contents) {
if ('musicCardShelfRenderer' in section) { 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 continue
} }
const sectionType = section.musicShelfRenderer.title.runs[0].text 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) parsedSearchResults.push(...parsedSectionContents)
} }
@@ -139,21 +159,22 @@ export class YouTubeMusic implements Connection {
const contents = browseResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents const contents = browseResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
const recommendations: (Song | Album | Artist | Playlist)[] = [] const recommendations: (Song | Album | Artist | Playlist)[] = []
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
for (const section of contents) { for (const section of contents) {
const header = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text const sectionType = 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' if (!goodSections.includes(sectionType)) continue
const parsedContent = section.musicCarouselShelfRenderer.contents.map((content) => 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) recommendations.push(...parsedContent)
} }
return recommendations return recommendations
} }
}
private parseTwoRowItemRenderer = (rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist => { const parseTwoRowItemRenderer = (connection: string, 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 name = rowContent.title.runs[0].text
let artists: (Song | Album)['artists'] let artists: (Song | Album)['artists']
@@ -180,10 +201,9 @@ export class YouTubeMusic implements Connection {
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist
} }
} }
private parseResponsiveListItemRenderer = (listContent: InnerTube.musicResponsiveListItemRenderer): Song | Album | Artist | Playlist => { const parseResponsiveListItemRenderer = (connection: string, 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 name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const thumbnail = refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) const thumbnail = refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
@@ -196,15 +216,17 @@ export class YouTubeMusic implements Connection {
if (!('navigationEndpoint' in listContent)) { if (!('navigationEndpoint' in listContent)) {
const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
const column2run = listContent.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text.runs?.[0] const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
const pageIsAlbum = column2run?.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM' let album: Song['album']
const album: Song['album'] = pageIsAlbum ? { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text } : undefined 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 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 const id = listContent.navigationEndpoint.browseEndpoint.browseId
const pageType = listContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
switch (pageType) { switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM': case 'MUSIC_PAGE_TYPE_ALBUM':
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album
@@ -213,10 +235,9 @@ export class YouTubeMusic implements Connection {
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist
} }
} }
private parseMusicCardShelfRenderer = (cardContent: InnerTube.musicCardShelfRenderer): Song | Album | Artist | Playlist => { const parseMusicCardShelfRenderer = (connection: string, 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 name = cardContent.title.runs[0].text
const thumbnail = refineThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) const thumbnail = refineThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
@@ -249,10 +270,11 @@ export class YouTubeMusic implements Connection {
case 'MUSIC_PAGE_TYPE_PLAYLIST': case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist return { connection, id, name, type: 'playlist', thumbnail } satisfies Playlist
} }
}
} }
const refineThumbnailUrl = (urlString: string): string => { const refineThumbnailUrl = (urlString: string): string => {
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
const url = new URL(urlString) const url = new URL(urlString)
if (url.origin === 'https://i.ytimg.com') { if (url.origin === 'https://i.ytimg.com') {
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault') return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
@@ -452,14 +474,14 @@ declare namespace InnerTube {
runs?: [ runs?: [
{ {
text: string text: string
navigationEndpoint: { navigationEndpoint?: {
browseEndpoint: browseEndpoint browseEndpoint: browseEndpoint
} }
}, },
] ]
} }
} }
}, }?,
] ]
} }
| { | {

View File

@@ -4,17 +4,18 @@ import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import type { PageServerLoad, Actions } from './$types' import type { PageServerLoad, Actions } from './$types'
import { DB } from '$lib/server/db' import { DB } from '$lib/server/db'
import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin' import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin'
import type { ConnectionInfo } from '$lib/server/connections'
import { google } from 'googleapis' import { google } from 'googleapis'
export const load: PageServerLoad = async ({ fetch, locals }) => { 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', method: 'GET',
headers: { apikey: SECRET_INTERNAL_API_KEY }, 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 = { export const actions: Actions = {
@@ -28,7 +29,7 @@ export const actions: Actions = {
if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message }) 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}`, { const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
method: 'GET', 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 userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! })
const userChannel = userChannelResponse.data.items![0] const userChannel = userChannelResponse.data.items![0]
const newConnectionId = DB.addConnectionInfo(locals.user.id, { const newConnectionId = DB.addConnectionInfo({
userId: locals.user.id,
type: 'youtube-music', type: 'youtube-music',
serviceInfo: { userId: userChannel.id! }, service: { userId: userChannel.id! },
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
}) })

View File

@@ -16,7 +16,7 @@
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
export let data: PageServerData & LayoutData export let data: PageServerData & LayoutData
let connections: ConnectionInfo[] = data.connections let connections = data.connections
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => { const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
const { serverUrl, username, password } = Object.fromEntries(formData) const { serverUrl, username, password } = Object.fromEntries(formData)
@@ -39,7 +39,7 @@
if (result.type === 'failure') { if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message]) return ($newestAlert = ['warning', result.data?.message])
} else if (result.type === 'success') { } else if (result.type === 'success') {
const newConnection: ConnectionInfo = result.data!.newConnection const newConnection = result.data!.newConnection
connections = [...connections, newConnection] connections = [...connections, newConnection]
newConnectionModal = null newConnectionModal = null
@@ -72,7 +72,7 @@
if (result.type === 'failure') { if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message]) return ($newestAlert = ['warning', result.data?.message])
} else if (result.type === 'success') { } else if (result.type === 'success') {
const newConnection: ConnectionInfo = result.data!.newConnection const newConnection = result.data!.newConnection
connections = [...connections, newConnection] connections = [...connections, newConnection]
return ($newestAlert = ['success', 'Added Youtube Music']) return ($newestAlert = ['success', 'Added Youtube Music'])
} }
@@ -135,7 +135,14 @@
</section> </section>
<div id="connection-profile-grid" class="grid gap-8"> <div id="connection-profile-grid" class="grid gap-8">
{#each connections as connection} {#each connections as connection}
<ConnectionProfile {connection} submitFunction={profileActions} /> <ConnectionProfile
id={connection.id}
type={connection.type}
username={connection.service.username}
profilePicture={'profilePicture' in connection.service ? connection.service.profilePicture : undefined}
serverName={'serverName' in connection.service ? connection.service.serverName : undefined}
submitFunction={profileActions}
/>
{/each} {/each}
</div> </div>
{#if newConnectionModal !== null} {#if newConnectionModal !== null}

View File

@@ -6,24 +6,22 @@
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition'
import { enhance } from '$app/forms' 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 export let submitFunction: SubmitFunction
$: serviceData = Services[connection.type] $: serviceData = Services[type]
let showModal = false let showModal = false
const subHeaderItems: string[] = [] const subHeaderItems = [username, serverName]
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)
</script> </script>
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}> <section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
<header class="flex h-20 items-center gap-4 p-4"> <header class="flex h-20 items-center gap-4 p-4">
<div class="relative aspect-square h-full p-1"> <div class="relative aspect-square h-full p-1">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" /> <img src={serviceData.icon} alt="{serviceData.displayName} icon" />
{#if 'profilePicture' in connection.serviceInfo && connection.serviceInfo.profilePicture} {#if profilePicture}
<img src={connection.serviceInfo.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" /> <img src={profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
{/if} {/if}
</div> </div>
<div> <div>
@@ -42,7 +40,7 @@
<i class="fa-solid fa-link-slash mr-1" /> <i class="fa-solid fa-link-slash mr-1" />
Delete Connection Delete Connection
</button> </button>
<input type="hidden" value={connection.id} name="connectionId" /> <input type="hidden" value={id} name="connectionId" />
</form> </form>
{/if} {/if}
</div> </div>

View File

@@ -1,5 +1,5 @@
import type { RequestHandler } from '@sveltejs/kit' 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 }) => { export const GET: RequestHandler = async ({ url }) => {
const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',') const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',')

View File

@@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ url }) => {
await connection await connection
.search(query) .search(query)
.then((results) => searchResults.push(...results)) .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 }) return Response.json({ searchResults })

View File

@@ -1,5 +1,5 @@
import type { RequestHandler } from '@sveltejs/kit' 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 }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userId = params.userId!

View File

@@ -6,7 +6,7 @@ import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userId = params.userId!
const recommendations: (Song | Album | Playlist)[] = [] const recommendations: (Song | Album | Artist | Playlist)[] = []
for (const connection of Connections.getUserConnections(userId)) { for (const connection of Connections.getUserConnections(userId)) {
await connection await connection
.getRecommendations() .getRecommendations()