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

View File

@@ -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)
}
}

View File

@@ -10,22 +10,34 @@ interface DBConnectionsTableSchema {
tokens?: string
}
type DBServiceInfo =
| {
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 = {
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<DBConnectionInfo, 'id'>): 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
}

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 {
public id: string
private userId: string
@@ -26,7 +41,7 @@ export class Jellyfin implements Connection {
// 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 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',

View File

@@ -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<YouTubeMusic.Tokens> => {
private getTokens = async (): Promise<YouTubeMusicConnectionInfo['tokens']> => {
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<Extract<ConnectionInfo, { type: 'youtube-music' }>> => {
public getConnectionInfo = async (): Promise<YouTubeMusicConnectionInfo> => {
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
}
},
]
}
}
},
}?,
]
}
| {