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

@@ -1,38 +1,10 @@
# create-svelte # Lazuli
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte). A self hosted client to stream music from all your favorite music streaming services all in one unified interface.
## Creating a project ## Planned Features
If you're seeing this, you've probably already done this step. Congrats! - Connect your exisiting accounts for personalized recommendations
- Search for content across all music platforms
```bash - Synchronize your playlist across every service
# create a new project in the current directory - Local downloads for offline playback
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

12
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"googleapis": "^133.0.0", "googleapis": "^133.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"pocketbase": "^0.21.1", "pocketbase": "^0.21.1",
"type-fest": "^4.12.0",
"ytdl-core": "^4.11.5", "ytdl-core": "^4.11.5",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@@ -3775,6 +3776,17 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/type-fest": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.12.0.tgz",
"integrity": "sha512-5Y2/pp2wtJk8o08G0CMkuFPCO354FGwk/vbidxrdhRGZfd0tFnb4Qb8anp9XxXriwBgVPjdWbKpGl4J9lJY2jQ==",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.3.3", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",

View File

@@ -35,6 +35,7 @@
"googleapis": "^133.0.0", "googleapis": "^133.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"pocketbase": "^0.21.1", "pocketbase": "^0.21.1",
"type-fest": "^4.12.0",
"ytdl-core": "^4.11.5", "ytdl-core": "^4.11.5",
"zod": "^3.22.4" "zod": "^3.22.4"
} }

136
src/app.d.ts vendored
View File

@@ -18,7 +18,7 @@ declare global {
// Do not store data from other services in the database, only the data necessary to fetch whatever you need. // Do not store data from other services in the database, only the data necessary to fetch whatever you need.
// This avoid syncronization issues. E.g. Store userId, and urlOrigin to fetch the user's name and profile picture. // This avoid syncronization issues. E.g. Store userId, and urlOrigin to fetch the user's name and profile picture.
interface User { type User = {
id: string id: string
username: string username: string
passwordHash: string passwordHash: string
@@ -26,8 +26,26 @@ declare global {
type serviceType = 'jellyfin' | 'youtube-music' type serviceType = 'jellyfin' | 'youtube-music'
type Connection<T extends serviceType> = T extends 'jellyfin' ? Jellyfin.Connection : T extends 'youtube-music' ? YouTubeMusic.Connection : never 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 {
getRecommendations: () => Promise<MediaItem[]>
getConnectionInfo: () => Promise<ConnectionInfo>
}
// These Schemas should only contain general info data that is necessary for data fetching purposes. // These Schemas should only contain general info data that is necessary for data fetching purposes.
// They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type. // They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type.
// 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.
@@ -39,8 +57,10 @@ declare global {
} }
interface Song extends MediaItem { interface Song extends MediaItem {
connectionId: string connection: {
serviceType: serviceType id: string
type: serviceType
}
type: 'song' type: 'song'
duration?: number duration?: number
artists?: { artists?: {
@@ -57,8 +77,10 @@ declare global {
} }
interface Album extends MediaItem { interface Album extends MediaItem {
connectionId: string connection: {
serviceType: serviceType id: string
type: serviceType
}
type: 'album' type: 'album'
duration?: number duration?: number
artists?: { artists?: {
@@ -87,101 +109,29 @@ declare global {
// The jellyfin API will not always return the data it says it will, for example /Users/AuthenticateByName says it will // 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. // 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) // So, ONLY DEFINE THE INTERFACES FOR DATA THAT IS GARUNTEED TO BE RETURNED (unless the data value itself is inherently optional)
interface Connection { type SerivceInfo = {
id: string
userId: string userId: string
type: 'jellyfin' urlOrigin: string
service: { username?: string
userId: string serverName?: string
urlOrigin: string
username?: string
serverName?: string
}
tokens: {
accessToken: string
}
} }
interface User { type Tokens = {
Name: string accessToken: string
Id: string
}
interface AuthData {
User: Jellyfin.User
AccessToken: string
}
interface System {
ServerName: string
}
interface MediaItem {
Name: string
Id: string
Type: 'Audio' | 'MusicAlbum' | 'Playlist' | 'MusicArtist'
ImageTags?: {
Primary?: string
}
}
interface Song extends Jellyfin.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 Jellyfin.MediaItem {
RunTimeTicks: number
ProductionYear: number
Type: 'MusicAlbum'
ArtistItems: {
Name: string
Id: string
}[]
AlbumArtists: {
Name: string
Id: string
}[]
}
interface Playlist extends Jellyfin.MediaItem {
RunTimeTicks: number
Type: 'Playlist'
ChildCount: number
}
interface Artist extends Jellyfin.MediaItem {
Type: 'MusicArtist'
} }
} }
namespace YouTubeMusic { namespace YouTubeMusic {
interface Connection { type SerivceInfo = {
id: string
userId: string userId: string
type: 'youtube-music' username?: string
service: { profilePicture?: string
userId: string }
username?: string
profilePicture?: string type Tokens = {
} accessToken: string
tokens: { refreshToken: string
accessToken: string expiry: number
refreshToken: string
expiry: number
}
} }
interface HomeItems { interface HomeItems {

View File

@@ -1,7 +1,5 @@
import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit' import { redirect, type Handle } from '@sveltejs/kit'
import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY } from '$env/static/private'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { Connections } from '$lib/server/users'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
@@ -27,31 +25,3 @@ export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event) const response = await resolve(event)
return response return response
} }
// Access token refresh middleware - checks for expired connections and refreshes them accordingly
export const handleFetch: HandleFetch = async ({ request, fetch, event }) => {
if (event.locals.user) {
const expiredConnection = Connections.getExpiredConnections(event.locals.user.id)
for (const connection of expiredConnection) {
switch (connection.type) {
case 'youtube-music':
// Again DON'T SHIP THIS, CLIENT SECRET SHOULD NOT BE EXPOSED TO USERS
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: connection.tokens.refreshToken as string,
grant_type: 'refresh_token',
}),
})
const { access_token, expires_in } = await response.json()
const newExpiry = Date.now() + expires_in * 1000
Connections.updateTokens(connection.id, access_token, connection.tokens.refreshToken, newExpiry)
console.log('Refreshed YouTubeMusic access token')
}
}
}
return fetch(request)
}

View File

@@ -32,7 +32,7 @@
{#if checkSongOrAlbum(mediaItem) && 'artists' in mediaItem && mediaItem.artists} {#if checkSongOrAlbum(mediaItem) && '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.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} {#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

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

View File

@@ -1,25 +1,16 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Jellyfin } from '$lib/services' import { Connections } from '$lib/server/connections'
import { YouTubeMusic } from '$lib/service-managers/youtube-music'
import { Connections } from '$lib/server/users'
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(',')
if (!ids) return new Response('Missing ids query parameter', { status: 400 }) if (!ids) return new Response('Missing ids query parameter', { status: 400 })
const connections: Connection<serviceType>[] = [] const connections: ConnectionInfo[] = []
for (const connectionId of ids) { for (const connection of Connections.getConnections(ids)) {
const connection = Connections.getConnection(connectionId) await connection
switch (connection.type) { .getConnectionInfo()
case 'jellyfin': .then((info) => connections.push(info))
connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken) .catch((reason) => console.log(`Failed to fetch connection info: ${reason}`))
break
case 'youtube-music':
const ytmusic = new YouTubeMusic(connection)
connection.service = await ytmusic.fetchServiceInfo()
break
}
connections.push(connection)
} }
return Response.json({ connections }) return Response.json({ connections })

View File

@@ -1,22 +1,15 @@
import { Connections } from '$lib/server/users'
import { Jellyfin } from '$lib/services'
import { YouTubeMusic } from '$lib/service-managers/youtube-music'
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
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 connections = Connections.getUserConnections(userId) const connections: ConnectionInfo[] = []
for (const connection of connections) { for (const connection of Connections.getUserConnections(userId)) {
switch (connection.type) { await connection
case 'jellyfin': .getConnectionInfo()
connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken) .then((info) => connections.push(info))
break .catch((reason) => console.log(`Failed to fetch connection info: ${reason}`))
case 'youtube-music':
const youTubeMusic = new YouTubeMusic(connection)
connection.service = await youTubeMusic.fetchServiceInfo()
break
}
} }
return Response.json({ connections }) return Response.json({ connections })

View File

@@ -1,49 +1,17 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private' import { Connections } from '$lib/server/connections'
import { Jellyfin } from '$lib/services'
import { YouTubeMusic } from '$lib/service-managers/youtube-music'
// This is temporary functionally for the sake of developing the app. // This is temporary functionally for the sake of developing the app.
// In the future will implement more robust algorithm for offering recommendations // In the future will implement more robust algorithm for offering recommendations
export const GET: RequestHandler = async ({ params, fetch }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userId = params.userId!
const connectionsResponse = await fetch(`/api/users/${userId}/connections`, { headers: { apikey: SECRET_INTERNAL_API_KEY } })
const userConnections = await connectionsResponse.json()
const recommendations: MediaItem[] = [] const recommendations: MediaItem[] = []
for (const connection of Connections.getUserConnections(userId)) {
for (const connection of userConnections.connections) { await connection
const { type, service, tokens } = connection as Connection<serviceType> .getRecommendations()
.then((connectionRecommendations) => recommendations.push(...connectionRecommendations))
switch (type) { .catch((reason) => console.log(`Failed to fetch recommendations: ${reason}`))
case 'jellyfin':
const mostPlayedSongsSearchParams = new URLSearchParams({
SortBy: 'PlayCount',
SortOrder: 'Descending',
IncludeItemTypes: 'Audio',
Recursive: 'true',
limit: '10',
})
const mostPlayedSongsURL = new URL(`/Users/${service.userId}/Items?${mostPlayedSongsSearchParams.toString()}`, service.urlOrigin).href
const requestHeaders = new Headers({ Authorization: `MediaBrowser Token="${tokens.accessToken}"` })
const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: requestHeaders })
const mostPlayedData = await mostPlayedResponse.json()
for (const song of mostPlayedData.Items) recommendations.push(Jellyfin.songFactory(song, connection))
break
case 'youtube-music':
const youtubeMusic = new YouTubeMusic(connection)
await youtubeMusic
.getHome()
.then(({ listenAgain, quickPicks, newReleases }) => {
for (const mediaItem of listenAgain) recommendations.push(mediaItem)
})
.catch()
break
}
} }
return Response.json({ recommendations }) return Response.json({ recommendations })

View File

@@ -2,7 +2,7 @@ import { SECRET_JWT_KEY } from '$env/static/private'
import { fail, redirect } from '@sveltejs/kit' import { fail, redirect } from '@sveltejs/kit'
import { compare, hash } from 'bcrypt-ts' import { compare, hash } from 'bcrypt-ts'
import type { PageServerLoad, Actions } from './$types' import type { PageServerLoad, Actions } from './$types'
import { Users } from '$lib/server/users' import { DB } from '$lib/server/db'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
export const load: PageServerLoad = async ({ url }) => { export const load: PageServerLoad = async ({ url }) => {
@@ -15,7 +15,7 @@ export const actions: Actions = {
const formData = await request.formData() const formData = await request.formData()
const { username, password, redirectLocation } = Object.fromEntries(formData) const { username, password, redirectLocation } = Object.fromEntries(formData)
const user = Users.getUsername(username.toString()) const user = DB.getUsername(username.toString())
if (!user) return fail(400, { message: 'Invalid Username' }) if (!user) return fail(400, { message: 'Invalid Username' })
const passwordValid = await compare(password.toString(), user.passwordHash) const passwordValid = await compare(password.toString(), user.passwordHash)
@@ -34,7 +34,7 @@ export const actions: Actions = {
const { username, password } = Object.fromEntries(formData) const { username, password } = Object.fromEntries(formData)
const passwordHash = await hash(password.toString(), 10) const passwordHash = await hash(password.toString(), 10)
const newUser = Users.addUser(username.toString(), passwordHash) const newUser = DB.addUser(username.toString(), passwordHash)
if (!newUser) return fail(400, { message: 'Username already in use' }) if (!newUser) return fail(400, { message: 'Username already in use' })
const authToken = jwt.sign({ id: newUser.id, username: newUser.username }, SECRET_JWT_KEY, { expiresIn: '100d' }) const authToken = jwt.sign({ id: newUser.id, username: newUser.username }, SECRET_JWT_KEY, { expiresIn: '100d' })

View File

@@ -2,7 +2,8 @@ import { fail } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import type { PageServerLoad, Actions } from './$types' import type { PageServerLoad, Actions } from './$types'
import { Connections } from '$lib/server/users' import { DB } from '$lib/server/db'
import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin'
import { google } from 'googleapis' import { google } from 'googleapis'
export const load: PageServerLoad = async ({ fetch, locals }) => { export const load: PageServerLoad = async ({ fetch, locals }) => {
@@ -21,38 +22,22 @@ export const actions: Actions = {
const formData = await request.formData() const formData = await request.formData()
const { serverUrl, username, password, deviceId } = Object.fromEntries(formData) const { serverUrl, username, password, deviceId } = Object.fromEntries(formData)
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.toString()).href if (!URL.canParse(serverUrl.toString())) return fail(400, { message: 'Invalid Server URL' })
let authData: Jellyfin.AuthData
try {
const authResponse = await 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"`,
},
})
if (!authResponse.ok) return fail(401, { message: 'Failed to authenticate' }) const authData = await Jellyfin.authenticateByName(username.toString(), password.toString(), new URL(serverUrl.toString()), deviceId.toString()).catch((error: JellyfinFetchError) => error)
authData = await authResponse.json() if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message })
} catch {
return fail(400, { message: 'Could not reach Jellyfin server' })
}
const newConnectionId = Connections.addConnection('jellyfin', locals.user.id, { userId: authData.User.Id, urlOrigin: serverUrl.toString() }, { accessToken: authData.AccessToken }) const newConnectionId = DB.addConnectionInfo(locals.user.id, { type: 'jellyfin', serviceInfo: { 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',
headers: { apikey: SECRET_INTERNAL_API_KEY }, headers: { apikey: SECRET_INTERNAL_API_KEY },
}).then((response) => {
return response.json()
}) })
const responseData = await response.json() return { newConnection: response.connections[0] }
return { newConnection: responseData.connections[0] }
}, },
youtubeMusicLogin: async ({ request, fetch, locals }) => { youtubeMusicLogin: async ({ request, fetch, locals }) => {
const formData = await request.formData() const formData = await request.formData()
@@ -64,12 +49,11 @@ 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 = Connections.addConnection( const newConnectionId = DB.addConnectionInfo(locals.user.id, {
'youtube-music', type: 'youtube-music',
locals.user.id, serviceInfo: { userId: userChannel.id! },
{ userId: userChannel.id! }, tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
{ accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, })
)
const response = await fetch(`/api/connections?ids=${newConnectionId}`, { const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
method: 'GET', method: 'GET',
@@ -84,7 +68,7 @@ export const actions: Actions = {
const formData = await request.formData() const formData = await request.formData()
const connectionId = formData.get('connectionId')!.toString() const connectionId = formData.get('connectionId')!.toString()
Connections.deleteConnection(connectionId) DB.deleteConnectionInfo(connectionId)
return { deletedConnectionId: connectionId } return { deletedConnectionId: connectionId }
}, },

View File

@@ -11,7 +11,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 export let data: PageServerData
let connections: Connection<serviceType>[] = data.connections let connections: ConnectionInfo[] = 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)
@@ -34,7 +34,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: Connection<'jellyfin'> = result.data!.newConnection const newConnection: ConnectionInfo = result.data!.newConnection
connections = [...connections, newConnection] connections = [...connections, newConnection]
newConnectionModal = null newConnectionModal = null
@@ -67,7 +67,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: Connection<'youtube-music'> = result.data!.newConnection const newConnection: ConnectionInfo = result.data!.newConnection
connections = [...connections, newConnection] connections = [...connections, newConnection]
return ($newestAlert = ['success', 'Added Youtube Music']) return ($newestAlert = ['success', 'Added Youtube Music'])
} }

View File

@@ -6,7 +6,7 @@
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition'
import { enhance } from '$app/forms' import { enhance } from '$app/forms'
export let connection: Connection<serviceType> export let connection: ConnectionInfo
export let submitFunction: SubmitFunction export let submitFunction: SubmitFunction
$: serviceData = Services[connection.type] $: serviceData = Services[connection.type]
@@ -14,16 +14,16 @@
let showModal = false let showModal = false
const subHeaderItems: string[] = [] const subHeaderItems: string[] = []
if ('username' in connection.service && connection.service.username) subHeaderItems.push(connection.service.username) if ('username' in connection.serviceInfo && connection.serviceInfo.username) subHeaderItems.push(connection.serviceInfo.username)
if ('serverName' in connection.service && connection.service.serverName) subHeaderItems.push(connection.service.serverName) 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.service && connection.service.profilePicture} {#if 'profilePicture' in connection.serviceInfo && connection.serviceInfo.profilePicture}
<img src={connection.service.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" /> <img src={connection.serviceInfo.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
{/if} {/if}
</div> </div>
<div> <div>