Going to try out some OOP/DI patterns and see where that takes me

This commit is contained in:
Eclypsed
2024-03-24 16:03:31 -04:00
parent d50497e7d5
commit 15db7f1aed
22 changed files with 894 additions and 900 deletions

View File

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

View File

@@ -0,0 +1,30 @@
import { DB } from './db'
import { Jellyfin } from './jellyfin'
import { YouTubeMusic } from './youtube-music'
const constructConnection = (connectionInfo: ConnectionInfo): Connection => {
const { id, userId, type, serviceInfo, tokens } = connectionInfo
switch (type) {
case 'jellyfin':
return new Jellyfin(id, userId, serviceInfo.userId, serviceInfo.urlOrigin, tokens.accessToken)
case 'youtube-music':
return new YouTubeMusic(id, userId, serviceInfo.userId, tokens)
}
}
const getConnections = (ids: string[]): Connection[] => {
const connectionInfo = DB.getConnectionInfo(ids)
return Array.from(connectionInfo, (info) => constructConnection(info))
}
const getUserConnections = (userId: string): Connection[] => {
const connectionInfo = DB.getUserConnectionInfo(userId)
return Array.from(connectionInfo, (info) => constructConnection(info))
}
export const Connections = {
getConnections,
getUserConnections,
}

123
src/lib/server/db.ts Normal file
View File

@@ -0,0 +1,123 @@
import Database from 'better-sqlite3'
import type { Database as Sqlite3DB } from 'better-sqlite3'
import { generateUUID } from '$lib/utils'
interface DBConnectionsTableSchema {
id: string
userId: string
type: serviceType
service?: string
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
}
type DBConnectionInfo = {
id: string
userId: string
} & DBServiceInfo
export class Storage {
private database: Sqlite3DB
constructor(database: Sqlite3DB) {
this.database = database
this.database.pragma('foreign_keys = ON')
this.database.exec(`CREATE TABLE IF NOT EXISTS Users(
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(30) UNIQUE NOT NULL,
passwordHash VARCHAR(72) NOT NULL
)`)
this.database.exec(`CREATE TABLE IF NOT EXISTS Connections(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
type VARCHAR(36) NOT NULL,
service TEXT,
tokens TEXT,
FOREIGN KEY(userId) REFERENCES Users(id)
)`)
this.database.exec(`CREATE TABLE IF NOT EXISTS Playlists(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
name TEXT NOT NULL,
description TEXT,
items TEXT,
FOREIGN KEY(userId) REFERENCES Users(id)
)`)
}
public getUser = (id: string): User | undefined => {
const user = this.database.prepare(`SELECT * FROM Users WHERE id = ?`).get(id) as User | undefined
return user
}
public getUsername = (username: string): User | undefined => {
const user = this.database.prepare(`SELECT * FROM Users WHERE lower(username) = ?`).get(username.toLowerCase()) as User | undefined
return user
}
public addUser = (username: string, passwordHash: string): User => {
const userId = generateUUID()
this.database.prepare(`INSERT INTO Users(id, username, passwordHash) VALUES(?, ?, ?)`).run(userId, username, passwordHash)
return this.getUser(userId)!
}
public deleteUser = (id: string): void => {
const commandInfo = this.database.prepare(`DELETE FROM Users WHERE id = ?`).run(id)
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
}
public getConnectionInfo = (ids: string[]): DBConnectionInfo[] => {
const connectionInfo: DBConnectionInfo[] = []
for (const id of ids) {
const result = this.database.prepare(`SELECT * FROM Connections WHERE id = ?`).get(id) as DBConnectionsTableSchema | undefined
if (!result) continue
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, serviceInfo: parsedService, tokens: parsedTokens })
}
return connectionInfo
}
public getUserConnectionInfo = (userId: string): DBConnectionInfo[] => {
const connectionRows = this.database.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as DBConnectionsTableSchema[]
const connections: DBConnectionInfo[] = []
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, serviceInfo: parsedService, tokens: parsedTokens })
}
return connections
}
public addConnectionInfo = (userId: string, serviceData: DBServiceInfo): string => {
const { type, serviceInfo, tokens } = serviceData
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))
return connectionId
}
public deleteConnectionInfo = (id: string): void => {
const commandInfo = this.database.prepare(`DELETE FROM Connections WHERE id = ?`).run(id)
if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
}
public updateTokens = (id: string, tokens: DBConnectionInfo['tokens']): void => {
const commandInfo = this.database.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(JSON.stringify(tokens), id)
if (commandInfo.changes === 0) throw new Error('Failed to update tokens')
}
}
export const DB = new Storage(new Database('./src/lib/server/lazuli.db', { verbose: console.info }))

199
src/lib/server/jellyfin.ts Normal file
View File

@@ -0,0 +1,199 @@
export class Jellyfin implements Connection {
private id: string
private userId: string
private jfUserId: string
private serverUrl: string
private accessToken: string
constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
this.id = id
this.userId = userId
this.jfUserId = jellyfinUserId
this.serverUrl = serverUrl
this.accessToken = accessToken
}
// const audoSearchParams = new URLSearchParams({
// MaxStreamingBitrate: '999999999',
// Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
// TranscodingContainer: 'ts',
// TranscodingProtocol: 'hls',
// AudioCodec: 'aac',
// userId: this.jfUserId,
// })
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'jellyfin' }>> => {
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` })
const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl).href
const systemUrl = new URL('System/Info', this.serverUrl).href
const userResponse = await fetch(userUrl, { headers: reqHeaders })
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
const userData: JellyfinAPI.User = await userResponse.json()
const systemData: JellyfinAPI.System = await systemResponse.json()
return {
id: this.id,
userId: this.userId,
type: 'jellyfin',
serviceInfo: {
userId: this.jfUserId,
urlOrigin: this.serverUrl,
username: userData.Name,
serverName: systemData.ServerName,
},
tokens: {
accessToken: this.accessToken,
},
}
}
public getRecommendations = async (): Promise<MediaItem[]> => {
const mostPlayedSongsSearchParams = new URLSearchParams({
SortBy: 'PlayCount',
SortOrder: 'Descending',
IncludeItemTypes: 'Audio',
Recursive: 'true',
limit: '10',
})
const mostPlayedSongsURL = new URL(`/Users/${this.jfUserId}/Items?${mostPlayedSongsSearchParams.toString()}`, this.serverUrl).href
const requestHeaders = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` })
const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: requestHeaders })
const mostPlayedData = await mostPlayedResponse.json()
return Array.from(mostPlayedData.Items as JellyfinAPI.Song[], (song) => this.songFactory(song))
}
private songFactory = (song: JellyfinAPI.Song): Song => {
const thumbnail = song.ImageTags?.Primary
? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).href
: song.AlbumPrimaryImageTag
? new URL(`Items/${song.AlbumId}/Images/Primary`, this.serverUrl).href
: undefined
const artists = song.ArtistItems
? Array.from(song.ArtistItems, (artist) => {
return { id: artist.Id, name: artist.Name }
})
: []
// Add Album details
return {
connection: {
id: this.id,
type: 'jellyfin',
},
type: 'song',
id: song.Id,
name: song.Name,
duration: Math.floor(song.RunTimeTicks / 10000),
thumbnail,
artists,
releaseDate: String(song.ProductionYear),
}
}
static authenticateByName = async (username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthData> => {
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString()
return fetch(authUrl, {
method: 'POST',
body: JSON.stringify({
Username: username,
Pw: password,
}),
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="1.0.0.0"`,
},
})
.catch(() => {
throw new JellyfinFetchError('Could not reach Jellyfin Server', 400, authUrl)
})
.then((response) => {
if (!response.ok) throw new JellyfinFetchError('Failed to Authenticate', 401, authUrl)
return response.json() as Promise<JellyfinAPI.AuthData>
})
}
}
export class JellyfinFetchError extends Error {
public httpCode: number
public url: string
constructor(message: string, httpCode: number, url: string) {
super(message)
this.httpCode = httpCode
this.url = url
}
}
declare namespace JellyfinAPI {
interface User {
Name: string
Id: string
}
interface AuthData {
User: JellyfinAPI.User
AccessToken: string
}
interface System {
ServerName: string
}
interface MediaItem {
Name: string
Id: string
Type: 'Audio' | 'MusicAlbum' | 'Playlist' | 'MusicArtist'
ImageTags?: {
Primary?: string
}
}
interface Song extends JellyfinAPI.MediaItem {
RunTimeTicks: number
ProductionYear: number
Type: 'Audio'
ArtistItems: {
Name: string
Id: string
}[]
Album?: string
AlbumId?: string
AlbumPrimaryImageTag?: string
AlbumArtists: {
Name: string
Id: string
}[]
}
interface Album extends JellyfinAPI.MediaItem {
RunTimeTicks: number
ProductionYear: number
Type: 'MusicAlbum'
ArtistItems: {
Name: string
Id: string
}[]
AlbumArtists: {
Name: string
Id: string
}[]
}
interface Playlist extends JellyfinAPI.MediaItem {
RunTimeTicks: number
Type: 'Playlist'
ChildCount: number
}
interface Artist extends JellyfinAPI.MediaItem {
Type: 'MusicArtist'
}
}

View File

@@ -1,127 +0,0 @@
import Database from 'better-sqlite3'
import { generateUUID } from '$lib/utils'
const db = new Database('./src/lib/server/users.db', { verbose: console.info })
db.pragma('foreign_keys = ON')
const initUsersTable = `CREATE TABLE IF NOT EXISTS Users(
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(30) UNIQUE NOT NULL,
passwordHash VARCHAR(72) NOT NULL
)`
const initConnectionsTable = `CREATE TABLE IF NOT EXISTS Connections(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
type VARCHAR(36) NOT NULL,
service TEXT,
tokens TEXT,
FOREIGN KEY(userId) REFERENCES Users(id)
)`
const initPlaylistsTable = `CREATE TABLE IF NOT EXISTS Playlists(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
name TEXT NOT NULL,
description TEXT,
items TEXT,
FOREIGN KEY(userId) REFERENCES Users(id)
)`
db.exec(initUsersTable), db.exec(initConnectionsTable), db.exec(initPlaylistsTable)
interface ConnectionsTableSchema {
id: string
userId: string
type: serviceType
service?: string
tokens?: string
}
export class Users {
static getUser = (id: string): User | null => {
const user = db.prepare(`SELECT * FROM Users WHERE id = ?`).get(id) as User | null
return user
}
static getUsername = (username: string): User | null => {
const user = db.prepare(`SELECT * FROM Users WHERE lower(username) = ?`).get(username.toLowerCase()) as User | null
return user
}
static addUser = (username: string, passwordHash: string): User | null => {
if (this.getUsername(username)) return null
const userId = generateUUID()
db.prepare(`INSERT INTO Users(id, username, passwordHash) VALUES(?, ?, ?)`).run(userId, username, passwordHash)
return this.getUser(userId)!
}
static deleteUser = (id: string): void => {
const commandInfo = db.prepare(`DELETE FROM Users WHERE id = ?`).run(id)
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
}
}
type DBServiceData<T extends serviceType> = T extends 'jellyfin' ? Pick<Jellyfin.Connection['service'], 'userId' | 'urlOrigin'> : T extends 'youtube-music' ? Pick<YouTubeMusic.Connection['service'], 'userId'> : never
type DBConnectionData<T extends serviceType> = T extends 'jellyfin'
? Omit<Jellyfin.Connection, 'service'> & { service: DBServiceData<'jellyfin'> }
: T extends 'youtube-music'
? Omit<YouTubeMusic.Connection, 'service'> & { service: DBServiceData<'youtube-music'> }
: never
export class Connections {
static getConnection = (id: string): DBConnectionData<serviceType> => {
const { userId, type, service, tokens } = db.prepare(`SELECT * FROM Connections WHERE id = ?`).get(id) as ConnectionsTableSchema
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
const connection: DBConnectionData<typeof type> = { id, userId, type, service: parsedService, tokens: parsedTokens }
return connection
}
static getUserConnections = (userId: string): DBConnectionData<serviceType>[] => {
const connectionRows = db.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as ConnectionsTableSchema[]
const connections: DBConnectionData<serviceType>[] = []
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, service: parsedService, tokens: parsedTokens })
}
return connections
}
static addConnection<T extends serviceType>(type: T, userId: string, service: DBServiceData<T>, tokens: Connection<T>['tokens']): string {
const connectionId = generateUUID()
db.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens))
return connectionId
}
static deleteConnection = (id: string): void => {
const commandInfo = db.prepare(`DELETE FROM Connections WHERE id = ?`).run(id)
if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
}
static updateTokens = (id: string, accessToken?: string, refreshToken?: string, expiry?: number): void => {
const newTokens = { accessToken, refreshToken, expiry }
const commandInfo = db.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(JSON.stringify(newTokens), id)
if (commandInfo.changes === 0) throw new Error('Failed to update tokens')
}
static getExpiredConnections = (userId: string): DBConnectionData<serviceType>[] => {
const expiredRows = db.prepare(`SELECT * FROM Connections WHERE userId = ? AND json_extract(tokens, '$.expiry') < ?`).all(userId, Date.now()) as ConnectionsTableSchema[]
const connections: DBConnectionData<serviceType>[] = []
for (const { id, userId, type, service, tokens } of expiredRows) {
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
connections.push({ id, userId, type, service: parsedService, tokens: parsedTokens })
}
return connections
}
}
class Playlists {
static newPlaylist = (userId: string, name: string): string => {
const playlistId = generateUUID()
db.prepare(`INSERT INTO Playlists(id, userId, name) VALUES(?, ?, ?)`).run(playlistId, userId, name)
return playlistId
}
static updateDescription = (id: string, description: string): void => {}
}

View File

@@ -0,0 +1,429 @@
import { google } from 'googleapis'
import { DB } from './db'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
export class YouTubeMusic implements Connection {
private id: string
private userId: string
private ytUserId: string
private tokens: YouTubeMusic.Tokens
constructor(id: string, userId: string, youtubeUserId: string, tokens: YouTubeMusic.Tokens) {
this.id = id
this.userId = userId
this.ytUserId = youtubeUserId
this.tokens = tokens
}
private BASEHEADERS = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
accept: '*/*',
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json',
'content-encoding': 'gzip',
origin: 'https://music.youtube.com',
Cookie: 'SOCS=CAI;',
}
private getTokens = async (): Promise<YouTubeMusic.Tokens> => {
if (this.tokens.expiry < Date.now()) {
const refreshToken = this.tokens.refreshToken
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: JSON.stringify({
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
client_secret: YOUTUBE_API_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
})
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 }
DB.updateTokens(this.id, newTokens)
this.tokens = newTokens
}
return this.tokens
}
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'youtube-music' }>> => {
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]
return {
id: this.id,
userId: this.userId,
type: 'youtube-music',
serviceInfo: {
userId: this.ytUserId,
username: userChannel.snippet?.title as string,
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
},
tokens: await this.getTokens(),
}
}
public getRecommendations = async (): Promise<MediaItem[]> => {
const { listenAgain, quickPicks } = await this.getHome()
return listenAgain.concat(quickPicks)
}
private getHome = async (): Promise<YouTubeMusic.HomeItems> => {
const headers = Object.assign(this.BASEHEADERS, { authorization: `Bearer ${(await this.getTokens()).accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` })
const response = await fetch(`https://music.youtube.com/youtubei/v1/browse`, {
headers,
method: 'POST',
body: JSON.stringify({
browseId: 'FEmusic_home',
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${formatDate()}.01.00`,
hl: 'en',
},
},
}),
})
const data: InnerTube.BrowseResponse = await response.json()
const contents = data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
const homeItems: YouTubeMusic.HomeItems = {
listenAgain: [],
quickPicks: [],
newReleases: [],
}
for (const section of contents) {
const headerTitle = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text
const rawContents = section.musicCarouselShelfRenderer.contents
const parsedContent: MediaItem[] =
'musicTwoRowItemRenderer' in rawContents[0]
? this.parseTwoRowItemRenderer((rawContents as { musicTwoRowItemRenderer: InnerTube.musicTwoRowItemRenderer }[]).map((item) => item.musicTwoRowItemRenderer))
: this.parseResponsiveListItemRenderer((rawContents as { musicResponsiveListItemRenderer: InnerTube.musicResponsiveListItemRenderer }[]).map((item) => item.musicResponsiveListItemRenderer))
if (headerTitle === 'Listen again') {
homeItems.listenAgain = parsedContent
} else if (headerTitle === 'Quick picks') {
homeItems.quickPicks = parsedContent
} else if (headerTitle === 'New releases') {
homeItems.newReleases = parsedContent
}
}
return homeItems
}
private parseTwoRowItemRenderer = (rowContent: InnerTube.musicTwoRowItemRenderer[]): MediaItem[] => {
const parsedContent: MediaItem[] = []
for (const data of rowContent) {
const title = data.title.runs[0].text
const subtitles = data.subtitle.runs
const artists: Song['artists'] | Album['artists'] = []
for (const subtitle of subtitles) {
if (subtitle.navigationEndpoint && 'browseEndpoint' in subtitle.navigationEndpoint) {
artists.push({ id: subtitle.navigationEndpoint.browseEndpoint.browseId, name: subtitle.text })
}
}
if ('browseEndpoint' in data.navigationEndpoint && data.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
const album: Album = {
connection: {
id: this.id,
type: 'youtube-music',
},
type: 'album',
id: data.navigationEndpoint.browseEndpoint.browseId,
name: title,
thumbnail: refineThumbnailUrl(data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url),
}
if (artists.length > 0) album.artists = artists
parsedContent.push(album)
} else if ('watchEndpoint' in data.navigationEndpoint) {
const song: Song = {
connection: {
id: this.id,
type: 'youtube-music',
},
type: 'song',
id: data.navigationEndpoint.watchEndpoint.videoId,
name: title,
thumbnail: refineThumbnailUrl(data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url),
}
if (artists.length > 0) song.artists = artists
parsedContent.push(song)
}
}
return parsedContent
}
private parseResponsiveListItemRenderer = (listContent: InnerTube.musicResponsiveListItemRenderer[]): Song[] => {
const parsedContent: Song[] = []
for (const data of listContent) {
const title = data.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
id = (data.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint! as { watchEndpoint: InnerTube.watchEndpoint }).watchEndpoint.videoId
const artists: Song['artists'] = []
for (const run of data.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs) {
if ('navigationEndpoint' in run && 'browseEndpoint' in run.navigationEndpoint!) {
artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
}
}
const thumbnail: MediaItem['thumbnail'] = refineThumbnailUrl(data.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const song: Song = {
connection: {
id: this.id,
type: 'youtube-music',
},
type: 'song',
id,
name: title,
thumbnail,
}
if (artists.length > 0) song.artists = artists
// This is like the ONE situation where `text` might not have a run
if (data.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text?.runs) {
song.album = {
id: (data.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint! as { browseEndpoint: InnerTube.browseEndpoint }).browseEndpoint.browseId,
name: data.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
}
}
parsedContent.push(song)
}
return parsedContent
}
}
const refineThumbnailUrl = (urlString: string): string => {
const url = new URL(urlString)
if (url.origin === 'https://i.ytimg.com') {
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
} else if (url.origin === 'https://lh3.googleusercontent.com' || url.origin === 'https://yt3.googleusercontent.com' || url.origin === 'https://yt3.ggpht.com') {
return urlString.slice(0, urlString.indexOf('='))
} else {
console.log(urlString)
throw new Error('Invalid thumbnail url origin')
}
}
const formatDate = (): string => {
const currentDate = new Date()
const year = currentDate.getUTCFullYear()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0')
return year + month + day
}
declare namespace InnerTube {
type Response = BrowseResponse | OtherResponse
interface OtherResponse {
contents: object
}
interface BrowseResponse {
responseContext: {
visitorData: string
serviceTrackingParams: object[]
maxAgeSeconds: number
}
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
endpoint: object
title: 'Home'
selected: boolean
content: {
sectionListRenderer: {
contents: {
musicCarouselShelfRenderer: musicCarouselShelfRenderer
}[]
continuations: [object]
trackingParams: string
header: {
chipCloudRenderer: object
}
}
}
icon: object
tabIdentifier: 'FEmusic_home'
trackingParams: string
}
},
]
}
}
trackingParams: string
maxAgeStoreSeconds: number
background: {
musicThumbnailRenderer: {
thumbnail: object
thumbnailCrop: string
thumbnailScale: string
trackingParams: string
}
}
}
type musicCarouselShelfRenderer = {
header: {
musicCarouselShelfBasicHeaderRenderer: {
title: {
runs: [runs]
}
strapline: [runs]
accessibilityData: accessibilityData
headerStyle: string
moreContentButton?: {
buttonRenderer: {
style: string
text: {
runs: [runs]
}
navigationEndpoint: navigationEndpoint
trackingParams: string
accessibilityData: accessibilityData
}
}
thumbnail?: musicThumbnailRenderer
trackingParams: string
}
}
contents:
| {
musicTwoRowItemRenderer: musicTwoRowItemRenderer
}[]
| {
musicResponsiveListItemRenderer: musicResponsiveListItemRenderer
}[]
trackingParams: string
itemSize: string
}
type musicDescriptionShelfRenderer = {
header: {
runs: [runs]
}
description: {
runs: [runs]
}
}
type musicTwoRowItemRenderer = {
thumbnailRenderer: {
musicThumbnailRenderer: musicThumbnailRenderer
}
aspectRatio: string
title: {
runs: [runs]
}
subtitle: {
runs: runs[]
}
navigationEndpoint: navigationEndpoint
trackingParams: string
menu: unknown
thumbnailOverlay: unknown
}
type musicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: musicThumbnailRenderer
}
overlay: unknown
flexColumns: {
musicResponsiveListItemFlexColumnRenderer: {
text: { runs: [runs] }
}
}[]
menu: unknown
playlistItemData: {
videoId: string
}
}
type musicThumbnailRenderer = {
thumbnail: {
thumbnails: {
url: string
width: number
height: number
}[]
}
thumbnailCrop: string
thumbnailScale: string
trackingParams: string
accessibilityData?: accessibilityData
onTap?: navigationEndpoint
targetId?: string
}
type runs = {
text: string
navigationEndpoint?: navigationEndpoint
}
type navigationEndpoint = {
clickTrackingParams: string
} & (
| {
browseEndpoint: browseEndpoint
}
| {
watchEndpoint: watchEndpoint
}
| {
watchPlaylistEndpoint: watchPlaylistEndpoint
}
)
type browseEndpoint = {
browseId: string
params?: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST'
}
}
}
type watchEndpoint = {
videoId: string
playlistId: string
params?: string
loggingContext: {
vssLoggingContext: object
}
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: object
}
}
type watchPlaylistEndpoint = {
playlistId: string
params?: string
}
type accessibilityData = {
accessibilityData: {
label: string
}
}
}

View File

@@ -1,192 +0,0 @@
export namespace InnerTube {
interface BrowseResponse {
responseContext: {
visitorData: string
serviceTrackingParams: object[]
maxAgeSeconds: number
}
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
endpoint: object
title: 'Home'
selected: boolean
content: {
sectionListRenderer: {
contents: {
musicCarouselShelfRenderer: musicCarouselShelfRenderer
}[]
continuations: [object]
trackingParams: string
header: {
chipCloudRenderer: object
}
}
}
icon: object
tabIdentifier: 'FEmusic_home'
trackingParams: string
}
},
]
}
}
trackingParams: string
maxAgeStoreSeconds: number
background: {
musicThumbnailRenderer: {
thumbnail: object
thumbnailCrop: string
thumbnailScale: string
trackingParams: string
}
}
}
type musicCarouselShelfRenderer = {
header: {
musicCarouselShelfBasicHeaderRenderer: {
title: {
runs: [runs]
}
strapline: [runs]
accessibilityData: accessibilityData
headerStyle: string
moreContentButton?: {
buttonRenderer: {
style: string
text: {
runs: [runs]
}
navigationEndpoint: navigationEndpoint
trackingParams: string
accessibilityData: accessibilityData
}
}
thumbnail?: musicThumbnailRenderer
trackingParams: string
}
}
contents:
| {
musicTwoRowItemRenderer: musicTwoRowItemRenderer
}[]
| {
musicResponsiveListItemRenderer: musicResponsiveListItemRenderer
}[]
trackingParams: string
itemSize: string
}
type musicDescriptionShelfRenderer = {
header: {
runs: [runs]
}
description: {
runs: [runs]
}
}
type musicTwoRowItemRenderer = {
thumbnailRenderer: {
musicThumbnailRenderer: musicThumbnailRenderer
}
aspectRatio: string
title: {
runs: [runs]
}
subtitle: {
runs: runs[]
}
navigationEndpoint: navigationEndpoint
trackingParams: string
menu: unknown
thumbnailOverlay: unknown
}
type musicResponsiveListItemRenderer = {
thumbnail: {
musicThumbnailRenderer: musicThumbnailRenderer
}
overlay: unknown
flexColumns: {
musicResponsiveListItemFlexColumnRenderer: {
text: { runs: [runs] }
}
}[]
menu: unknown
playlistItemData: {
videoId: string
}
}
type musicThumbnailRenderer = {
thumbnail: {
thumbnails: {
url: string
width: number
height: number
}[]
}
thumbnailCrop: string
thumbnailScale: string
trackingParams: string
accessibilityData?: accessibilityData
onTap?: navigationEndpoint
targetId?: string
}
type runs = {
text: string
navigationEndpoint?: navigationEndpoint
}
type navigationEndpoint = {
clickTrackingParams: string
} & (
| {
browseEndpoint: browseEndpoint
}
| {
watchEndpoint: watchEndpoint
}
| {
watchPlaylistEndpoint: watchPlaylistEndpoint
}
)
type browseEndpoint = {
browseId: string
params?: string
browseEndpointContextSupportedConfigs: {
browseEndpointContextMusicConfig: {
pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST'
}
}
}
type watchEndpoint = {
videoId: string
playlistId: string
params?: string
loggingContext: {
vssLoggingContext: object
}
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: object
}
}
type watchPlaylistEndpoint = {
playlistId: string
params?: string
}
type accessibilityData = {
accessibilityData: {
label: string
}
}
}

View File

@@ -1,186 +0,0 @@
import { google } from 'googleapis'
import type { InnerTube } from './youtube-music-types.d.ts'
// TODO: Change hook based token refresh to YouTubeMusic class middleware
export class YouTubeMusic {
connectionId: string
userId: string
accessToken: string
constructor(connection: YouTubeMusic.Connection) {
this.connectionId = connection.id
this.userId = connection.service.userId
this.accessToken = connection.tokens.accessToken
}
private BASEHEADERS = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
accept: '*/*',
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json',
'content-encoding': 'gzip',
origin: 'https://music.youtube.com',
Cookie: 'SOCS=CAI;',
}
public fetchServiceInfo = async (): Promise<YouTubeMusic.Connection['service']> => {
const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: this.accessToken })
const userChannel = userChannelResponse.data.items![0]
return {
userId: this.userId,
username: userChannel.snippet?.title as string,
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
}
}
private formatDate = (): string => {
const currentDate = new Date()
const year = currentDate.getUTCFullYear()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0')
return year + month + day
}
public getHome = async (): Promise<YouTubeMusic.HomeItems> => {
const headers = Object.assign(this.BASEHEADERS, { authorization: `Bearer ${this.accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` })
const response = await fetch(`https://music.youtube.com/youtubei/v1/browse?alt=json`, {
headers,
method: 'POST',
body: JSON.stringify({
browseId: 'FEmusic_home',
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: '1.' + this.formatDate() + '.01.00',
hl: 'en',
},
},
}),
})
const data: InnerTube.BrowseResponse = await response.json()
const contents = data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
const homeItems: YouTubeMusic.HomeItems = {
listenAgain: [],
quickPicks: [],
newReleases: [],
}
for (const section of contents) {
const headerTitle = section.musicCarouselShelfRenderer.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text
const rawContents = section.musicCarouselShelfRenderer.contents
const parsedContent: MediaItem[] =
'musicTwoRowItemRenderer' in rawContents[0]
? this.parseTwoRowItemRenderer((rawContents as { musicTwoRowItemRenderer: InnerTube.musicTwoRowItemRenderer }[]).map((item) => item.musicTwoRowItemRenderer))
: this.parseResponsiveListItemRenderer((rawContents as { musicResponsiveListItemRenderer: InnerTube.musicResponsiveListItemRenderer }[]).map((item) => item.musicResponsiveListItemRenderer))
if (headerTitle === 'Listen again') {
homeItems.listenAgain = parsedContent
} else if (headerTitle === 'Quick picks') {
homeItems.quickPicks = parsedContent
} else if (headerTitle === 'New releases') {
homeItems.newReleases = parsedContent
}
}
return homeItems
}
private parseTwoRowItemRenderer = (rowContent: InnerTube.musicTwoRowItemRenderer[]): MediaItem[] => {
const parsedContent: MediaItem[] = []
for (const data of rowContent) {
const title = data.title.runs[0].text
const subtitles = data.subtitle.runs
const artists: Song['artists'] | Album['artists'] = []
for (const subtitle of subtitles) {
if (subtitle.navigationEndpoint && 'browseEndpoint' in subtitle.navigationEndpoint) {
artists.push({ id: subtitle.navigationEndpoint.browseEndpoint.browseId, name: subtitle.text })
}
}
if ('browseEndpoint' in data.navigationEndpoint && data.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
const album: Album = {
connectionId: this.connectionId,
serviceType: 'youtube-music',
type: 'album',
id: data.navigationEndpoint.browseEndpoint.browseId,
name: title,
thumbnail: this.refineThumbnailUrl(data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url),
}
if (artists.length > 0) album.artists = artists
parsedContent.push(album)
} else if ('watchEndpoint' in data.navigationEndpoint) {
const song: Song = {
connectionId: this.connectionId,
serviceType: 'youtube-music',
type: 'song',
id: data.navigationEndpoint.watchEndpoint.videoId,
name: title,
thumbnail: this.refineThumbnailUrl(data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url),
}
if (artists.length > 0) song.artists = artists
parsedContent.push(song)
}
}
return parsedContent
}
private parseResponsiveListItemRenderer = (listContent: InnerTube.musicResponsiveListItemRenderer[]): Song[] => {
const parsedContent: Song[] = []
for (const data of listContent) {
const title = data.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
id = (data.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint! as { watchEndpoint: InnerTube.watchEndpoint }).watchEndpoint.videoId
const artists: Song['artists'] = []
for (const run of data.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs) {
if ('navigationEndpoint' in run && 'browseEndpoint' in run.navigationEndpoint!) {
artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
}
}
const thumbnail: MediaItem['thumbnail'] = this.refineThumbnailUrl(data.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const song: Song = {
connectionId: this.connectionId,
serviceType: 'youtube-music',
type: 'song',
id,
name: title,
thumbnail,
}
if (artists.length > 0) song.artists = artists
// This is like the ONE situation where `text` might not have a run
if (data.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text?.runs) {
song.album = {
id: (data.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint! as { browseEndpoint: InnerTube.browseEndpoint }).browseEndpoint.browseId,
name: data.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
}
}
parsedContent.push(song)
}
return parsedContent
}
private refineThumbnailUrl = (urlString: string): string => {
const url = new URL(urlString)
if (url.origin === 'https://i.ytimg.com') {
return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault')
} else if (url.origin === 'https://lh3.googleusercontent.com' || url.origin === 'https://yt3.googleusercontent.com' || url.origin === 'https://yt3.ggpht.com') {
return urlString.slice(0, urlString.indexOf('='))
} else {
console.log(urlString)
throw new Error('Invalid thumbnail url origin')
}
}
}

View File

@@ -1,123 +0,0 @@
export class Jellyfin {
static audioPresets = (userId: string) => {
return {
MaxStreamingBitrate: '999999999',
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
TranscodingContainer: 'ts',
TranscodingProtocol: 'hls',
AudioCodec: 'aac',
userId,
}
}
static fetchSerivceInfo = async (userId: string, urlOrigin: string, accessToken: string): Promise<Connection<'jellyfin'>['service']> => {
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
const userUrl = new URL(`Users/${userId}`, urlOrigin).href
const systemUrl = new URL('System/Info', urlOrigin).href
const userResponse = await fetch(userUrl, { headers: reqHeaders })
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
const userData: Jellyfin.User = await userResponse.json()
const systemData: Jellyfin.System = await systemResponse.json()
return {
userId,
urlOrigin,
username: userData.Name,
serverName: systemData.ServerName,
}
}
static songFactory = (song: Jellyfin.Song, connection: Connection<'jellyfin'>): Song => {
const { id, type, service } = connection
const thumbnail = song.ImageTags?.Primary
? new URL(`Items/${song.Id}/Images/Primary`, service.urlOrigin).href
: song.AlbumPrimaryImageTag
? new URL(`Items/${song.AlbumId}/Images/Primary`, service.urlOrigin).href
: undefined
const artists = song.ArtistItems
? Array.from(song.ArtistItems, (artist) => {
return { id: artist.Id, name: artist.Name }
})
: []
const audoSearchParams = new URLSearchParams(this.audioPresets(service.userId))
const audioSource = new URL(`Audio/${song.Id}/universal?${audoSearchParams.toString()}`, service.urlOrigin).href
return {
connectionId: id,
serviceType: type,
type: 'song',
id: song.Id,
name: song.Name,
duration: Math.floor(song.RunTimeTicks / 10000),
thumbnail,
artists,
albumId: song.AlbumId,
audio: audioSource,
releaseDate: String(song.ProductionYear),
}
}
static albumFactory = (album: Jellyfin.Album, connection: Connection<'jellyfin'>): Album => {
const { id, type, service } = connection
const thumbnail = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, service.urlOrigin).href : undefined
const albumArtists = album.AlbumArtists
? Array.from(album.AlbumArtists, (artist) => {
return { id: artist.Id, name: artist.Name }
})
: []
const artists = album.ArtistItems
? Array.from(album.ArtistItems, (artist) => {
return { id: artist.Id, name: artist.Name }
})
: []
return {
connectionId: id,
serviceType: type,
type: 'album',
id: album.Id,
name: album.Name,
duration: Math.floor(album.RunTimeTicks / 10000),
thumbnail,
albumArtists,
artists,
releaseDate: String(album.ProductionYear),
}
}
static playListFactory = (playlist: Jellyfin.Playlist, connection: Connection<'jellyfin'>): Playlist => {
const { id, type, service } = connection
const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, service.urlOrigin).href : undefined
return {
connectionId: id,
serviceType: type,
type: 'playlist',
id: playlist.Id,
name: playlist.Name,
duration: Math.floor(playlist.RunTimeTicks / 10000),
thumbnail,
}
}
static artistFactory = (artist: Jellyfin.Artist, connection: Connection<'jellyfin'>): Artist => {
const { id, type, service } = connection
const thumbnail = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, service.urlOrigin).href : undefined
return {
connectionId: id,
serviceType: type,
type: 'artist',
id: artist.Id,
name: artist.Name,
thumbnail,
}
}
}