From 15db7f1aed0bbb021803f090a5c35fc7cd21ed77 Mon Sep 17 00:00:00 2001 From: Eclypsed Date: Sun, 24 Mar 2024 16:03:31 -0400 Subject: [PATCH] Going to try out some OOP/DI patterns and see where that takes me --- README.md | 42 +- package-lock.json | 12 + package.json | 1 + src/app.d.ts | 136 ++---- src/hooks.server.ts | 34 +- src/lib/components/media/mediaCard.svelte | 2 +- src/lib/server/connections.ts | 30 ++ src/lib/server/db.ts | 123 +++++ src/lib/server/jellyfin.ts | 199 ++++++++ src/lib/server/{users.db => lazuli.db} | Bin 32768 -> 32768 bytes src/lib/server/users.ts | 127 ------ src/lib/server/youtube-music.ts | 429 ++++++++++++++++++ .../service-managers/youtube-music-types.d.ts | 192 -------- src/lib/service-managers/youtube-music.ts | 186 -------- src/lib/services.ts | 123 ----- src/routes/api/connections/+server.ts | 23 +- .../api/users/[userId]/connections/+server.ts | 21 +- .../users/[userId]/recommendations/+server.ts | 46 +- src/routes/login/+page.server.ts | 6 +- .../settings/connections/+page.server.ts | 46 +- src/routes/settings/connections/+page.svelte | 6 +- .../connections/connectionProfile.svelte | 10 +- 22 files changed, 894 insertions(+), 900 deletions(-) create mode 100644 src/lib/server/connections.ts create mode 100644 src/lib/server/db.ts create mode 100644 src/lib/server/jellyfin.ts rename src/lib/server/{users.db => lazuli.db} (92%) delete mode 100644 src/lib/server/users.ts create mode 100644 src/lib/server/youtube-music.ts delete mode 100644 src/lib/service-managers/youtube-music-types.d.ts delete mode 100644 src/lib/service-managers/youtube-music.ts delete mode 100644 src/lib/services.ts diff --git a/README.md b/README.md index 5ce6766..e835689 100644 --- a/README.md +++ b/README.md @@ -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! - -```bash -# create a new project in the current directory -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. +- Connect your exisiting accounts for personalized recommendations +- Search for content across all music platforms +- Synchronize your playlist across every service +- Local downloads for offline playback diff --git a/package-lock.json b/package-lock.json index c39790f..5efaab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "googleapis": "^133.0.0", "jsonwebtoken": "^9.0.2", "pocketbase": "^0.21.1", + "type-fest": "^4.12.0", "ytdl-core": "^4.11.5", "zod": "^3.22.4" }, @@ -3775,6 +3776,17 @@ "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": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", diff --git a/package.json b/package.json index f2d0972..921c47c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "googleapis": "^133.0.0", "jsonwebtoken": "^9.0.2", "pocketbase": "^0.21.1", + "type-fest": "^4.12.0", "ytdl-core": "^4.11.5", "zod": "^3.22.4" } diff --git a/src/app.d.ts b/src/app.d.ts index b0d94e8..eeedf6f 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -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. // 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 username: string passwordHash: string @@ -26,8 +26,26 @@ declare global { type serviceType = 'jellyfin' | 'youtube-music' - type Connection = 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 + getConnectionInfo: () => Promise + } // 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. // 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 { - connectionId: string - serviceType: serviceType + connection: { + id: string + type: serviceType + } type: 'song' duration?: number artists?: { @@ -57,8 +77,10 @@ declare global { } interface Album extends MediaItem { - connectionId: string - serviceType: serviceType + connection: { + id: string + type: serviceType + } type: 'album' duration?: number 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 // 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) - interface Connection { - id: string + type SerivceInfo = { userId: string - type: 'jellyfin' - service: { - userId: string - urlOrigin: string - username?: string - serverName?: string - } - tokens: { - accessToken: string - } + urlOrigin: string + username?: string + serverName?: string } - interface User { - Name: 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' + type Tokens = { + accessToken: string } } namespace YouTubeMusic { - interface Connection { - id: string + type SerivceInfo = { userId: string - type: 'youtube-music' - service: { - userId: string - username?: string - profilePicture?: string - } - tokens: { - accessToken: string - refreshToken: string - expiry: number - } + username?: string + profilePicture?: string + } + + type Tokens = { + accessToken: string + refreshToken: string + expiry: number } interface HomeItems { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 006571f..d062d62 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,7 +1,5 @@ -import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit' -import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' -import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' -import { Connections } from '$lib/server/users' +import { redirect, type Handle } from '@sveltejs/kit' +import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY } from '$env/static/private' import jwt from 'jsonwebtoken' export const handle: Handle = async ({ event, resolve }) => { @@ -27,31 +25,3 @@ export const handle: Handle = async ({ event, resolve }) => { const response = await resolve(event) 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) -} diff --git a/src/lib/components/media/mediaCard.svelte b/src/lib/components/media/mediaCard.svelte index 0d14fb4..42cd962 100644 --- a/src/lib/components/media/mediaCard.svelte +++ b/src/lib/components/media/mediaCard.svelte @@ -32,7 +32,7 @@ {#if checkSongOrAlbum(mediaItem) && 'artists' in mediaItem && mediaItem.artists} {#each mediaItem.artists as artist} {@const listIndex = mediaItem.artists.indexOf(artist)} - {artist.name} + {artist.name} {#if listIndex < mediaItem.artists.length - 1} , {/if} diff --git a/src/lib/server/connections.ts b/src/lib/server/connections.ts new file mode 100644 index 0000000..869e515 --- /dev/null +++ b/src/lib/server/connections.ts @@ -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, +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts new file mode 100644 index 0000000..a46e802 --- /dev/null +++ b/src/lib/server/db.ts @@ -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 + tokens: Jellyfin.Tokens + } + | { + type: 'youtube-music' + serviceInfo: Pick + 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 })) diff --git a/src/lib/server/jellyfin.ts b/src/lib/server/jellyfin.ts new file mode 100644 index 0000000..3a6bd1c --- /dev/null +++ b/src/lib/server/jellyfin.ts @@ -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> => { + 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 => { + 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 => { + 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 + }) + } +} + +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' + } +} diff --git a/src/lib/server/users.db b/src/lib/server/lazuli.db similarity index 92% rename from src/lib/server/users.db rename to src/lib/server/lazuli.db index 1eacbe3562eea476e0e8f50e0e7632db8cda1b7c..ed585bfd92765948411b59bc30fb78b48f22d4e2 100644 GIT binary patch delta 1109 zcma))%Wm676oxG~l^Vws(+i-5UB|K2q>IocIm4Tg4Xx-(Nt7wel8pi`hcl9?3rUf( zZbcB=35qULa33JqWYbOK0)2>}Z_r)QXJ~eb4IxEfAcYTb1_%F{nePYAIX<30KAwNN z6p8+_oLY*`Z47^==r?n*MQ$$sH~lC5f_fbPb&;bgzn%PXW8ri;8cW=|6?qa1UoXOI ztLG7NbaGZKQQypp`_%I0Me8Ck2`u0C2Da<0?xY(#_ZDXQ?6e1@7hKZAXL07U_m{$# z6kIsHb1jxwUynTbGW>_y{22B7_`3A|SwQ@uZIMeqUvvjvP=-;cx+iA3bI z9#ZtTmx$Z+#!UHj!}(aPcDMf1r;3DS*~AJXG))!C|g z1|Bh50QL)p&^LTi3|exr&F@c+V7)BrHmnFkargiZ$JI8_Tt^o?wU-$dq-i(X&7&;Q zQLfrSg~FiKo4EaUmMa^%{G?qTX~DSDb*=1CF6EW^{sX)1BZMkLGu7JD)T&igjZ!tU zoy#`(l8uSbgyr;6P^q@+T!$~ZQ}0k^If)A$J#m96Y60o&jtPn5o5Vkkd0e}VnE^IPf__VevWyHaZV+T2Q}_xBSw>G)ZSj{m?i z@sFubk9{<-GD<;HGWEtPTh;25?aV4nD>MRcH1UT1yY}6$H?aoc|L^!?jEEZ;y?yQ9 zPo@tJ5;q6&ci8cJa-7-O-w&-I@{Bvd9bq!;TmA=#Uv`8Cdx>N^olbn6j{n~I>>Z(i ze;&{9?CnqZYurV=P8t+z5s*SQL6gWN5XF!LPa$(vaJnT}bZg3C9z7ojA>J~AWhVhrR{G|}NX1R`7C+xDuG%uH%XUmeLl}~)d5{iX_W{6VZ zyrSePWY_DT&MIwtelC?bM{`Pz#*$;Z^(aRu^YN^wMWbPNK89z3%*q_3RO^CoIyt+i zE3N)4awMVCdhVC|H57m-s`tiHr+PUfDcy=mju;zh~(Ax_ptOrb+cXBEk08`D|D6rZZUhDC6A5( z<0*ZSSh_5<>nmPKY>J{Jw&c1#D-uyhJkxtXmh?g_K_QcmD!q)ykL<%COIGoG|vM_a`f##jbHKF RI)}6Q_+D$RMZfqW^Dk#|IV1o8 diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts deleted file mode 100644 index bf20062..0000000 --- a/src/lib/server/users.ts +++ /dev/null @@ -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 'jellyfin' ? Pick : T extends 'youtube-music' ? Pick : never - -type DBConnectionData = T extends 'jellyfin' - ? Omit & { service: DBServiceData<'jellyfin'> } - : T extends 'youtube-music' - ? Omit & { service: DBServiceData<'youtube-music'> } - : never - -export class Connections { - static getConnection = (id: string): DBConnectionData => { - 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 = { id, userId, type, service: parsedService, tokens: parsedTokens } - return connection - } - - static getUserConnections = (userId: string): DBConnectionData[] => { - const connectionRows = db.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as ConnectionsTableSchema[] - const connections: DBConnectionData[] = [] - 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(type: T, userId: string, service: DBServiceData, tokens: Connection['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[] => { - const expiredRows = db.prepare(`SELECT * FROM Connections WHERE userId = ? AND json_extract(tokens, '$.expiry') < ?`).all(userId, Date.now()) as ConnectionsTableSchema[] - const connections: DBConnectionData[] = [] - 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 => {} -} diff --git a/src/lib/server/youtube-music.ts b/src/lib/server/youtube-music.ts new file mode 100644 index 0000000..3905d66 --- /dev/null +++ b/src/lib/server/youtube-music.ts @@ -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 => { + 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> => { + 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 => { + const { listenAgain, quickPicks } = await this.getHome() + return listenAgain.concat(quickPicks) + } + + private getHome = async (): Promise => { + 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 + } + } +} diff --git a/src/lib/service-managers/youtube-music-types.d.ts b/src/lib/service-managers/youtube-music-types.d.ts deleted file mode 100644 index b1007fc..0000000 --- a/src/lib/service-managers/youtube-music-types.d.ts +++ /dev/null @@ -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 - } - } -} diff --git a/src/lib/service-managers/youtube-music.ts b/src/lib/service-managers/youtube-music.ts deleted file mode 100644 index 23c1b33..0000000 --- a/src/lib/service-managers/youtube-music.ts +++ /dev/null @@ -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 => { - 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 => { - 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') - } - } -} diff --git a/src/lib/services.ts b/src/lib/services.ts deleted file mode 100644 index a112d25..0000000 --- a/src/lib/services.ts +++ /dev/null @@ -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['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, - } - } -} diff --git a/src/routes/api/connections/+server.ts b/src/routes/api/connections/+server.ts index c945e59..4693496 100644 --- a/src/routes/api/connections/+server.ts +++ b/src/routes/api/connections/+server.ts @@ -1,25 +1,16 @@ import type { RequestHandler } from '@sveltejs/kit' -import { Jellyfin } from '$lib/services' -import { YouTubeMusic } from '$lib/service-managers/youtube-music' -import { Connections } from '$lib/server/users' +import { Connections } from '$lib/server/connections' export const GET: RequestHandler = async ({ url }) => { const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',') if (!ids) return new Response('Missing ids query parameter', { status: 400 }) - const connections: Connection[] = [] - for (const connectionId of ids) { - const connection = Connections.getConnection(connectionId) - switch (connection.type) { - case 'jellyfin': - connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken) - break - case 'youtube-music': - const ytmusic = new YouTubeMusic(connection) - connection.service = await ytmusic.fetchServiceInfo() - break - } - connections.push(connection) + const connections: ConnectionInfo[] = [] + for (const connection of Connections.getConnections(ids)) { + await connection + .getConnectionInfo() + .then((info) => connections.push(info)) + .catch((reason) => console.log(`Failed to fetch connection info: ${reason}`)) } return Response.json({ connections }) diff --git a/src/routes/api/users/[userId]/connections/+server.ts b/src/routes/api/users/[userId]/connections/+server.ts index 45c39e4..6ec20ca 100644 --- a/src/routes/api/users/[userId]/connections/+server.ts +++ b/src/routes/api/users/[userId]/connections/+server.ts @@ -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 { Connections } from '$lib/server/connections' export const GET: RequestHandler = async ({ params }) => { const userId = params.userId! - const connections = Connections.getUserConnections(userId) - for (const connection of connections) { - switch (connection.type) { - case 'jellyfin': - connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken) - break - case 'youtube-music': - const youTubeMusic = new YouTubeMusic(connection) - connection.service = await youTubeMusic.fetchServiceInfo() - break - } + const connections: ConnectionInfo[] = [] + for (const connection of Connections.getUserConnections(userId)) { + await connection + .getConnectionInfo() + .then((info) => connections.push(info)) + .catch((reason) => console.log(`Failed to fetch connection info: ${reason}`)) } return Response.json({ connections }) diff --git a/src/routes/api/users/[userId]/recommendations/+server.ts b/src/routes/api/users/[userId]/recommendations/+server.ts index d0b1166..626a78a 100644 --- a/src/routes/api/users/[userId]/recommendations/+server.ts +++ b/src/routes/api/users/[userId]/recommendations/+server.ts @@ -1,49 +1,17 @@ import type { RequestHandler } from '@sveltejs/kit' -import { SECRET_INTERNAL_API_KEY } from '$env/static/private' -import { Jellyfin } from '$lib/services' -import { YouTubeMusic } from '$lib/service-managers/youtube-music' +import { Connections } from '$lib/server/connections' // This is temporary functionally for the sake of developing the app. // 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 connectionsResponse = await fetch(`/api/users/${userId}/connections`, { headers: { apikey: SECRET_INTERNAL_API_KEY } }) - const userConnections = await connectionsResponse.json() - const recommendations: MediaItem[] = [] - - for (const connection of userConnections.connections) { - const { type, service, tokens } = connection as Connection - - switch (type) { - 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 - } + for (const connection of Connections.getUserConnections(userId)) { + await connection + .getRecommendations() + .then((connectionRecommendations) => recommendations.push(...connectionRecommendations)) + .catch((reason) => console.log(`Failed to fetch recommendations: ${reason}`)) } return Response.json({ recommendations }) diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 3a66877..51b7ca4 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -2,7 +2,7 @@ import { SECRET_JWT_KEY } from '$env/static/private' import { fail, redirect } from '@sveltejs/kit' import { compare, hash } from 'bcrypt-ts' import type { PageServerLoad, Actions } from './$types' -import { Users } from '$lib/server/users' +import { DB } from '$lib/server/db' import jwt from 'jsonwebtoken' export const load: PageServerLoad = async ({ url }) => { @@ -15,7 +15,7 @@ export const actions: Actions = { const formData = await request.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' }) const passwordValid = await compare(password.toString(), user.passwordHash) @@ -34,7 +34,7 @@ export const actions: Actions = { const { username, password } = Object.fromEntries(formData) 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' }) const authToken = jwt.sign({ id: newUser.id, username: newUser.username }, SECRET_JWT_KEY, { expiresIn: '100d' }) diff --git a/src/routes/settings/connections/+page.server.ts b/src/routes/settings/connections/+page.server.ts index 6a9a589..ac42d26 100644 --- a/src/routes/settings/connections/+page.server.ts +++ b/src/routes/settings/connections/+page.server.ts @@ -2,7 +2,8 @@ import { fail } from '@sveltejs/kit' import { SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' 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' export const load: PageServerLoad = async ({ fetch, locals }) => { @@ -21,38 +22,22 @@ export const actions: Actions = { const formData = await request.formData() const { serverUrl, username, password, deviceId } = Object.fromEntries(formData) - const authUrl = new URL('/Users/AuthenticateByName', serverUrl.toString()).href - 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 (!URL.canParse(serverUrl.toString())) return fail(400, { message: 'Invalid Server URL' }) - 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() - } catch { - return fail(400, { message: 'Could not reach Jellyfin server' }) - } + if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message }) - 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}`, { method: 'GET', headers: { apikey: SECRET_INTERNAL_API_KEY }, + }).then((response) => { + return response.json() }) - const responseData = await response.json() - - return { newConnection: responseData.connections[0] } + return { newConnection: response.connections[0] } }, youtubeMusicLogin: async ({ request, fetch, locals }) => { 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 userChannel = userChannelResponse.data.items![0] - const newConnectionId = Connections.addConnection( - 'youtube-music', - locals.user.id, - { userId: userChannel.id! }, - { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, - ) + const newConnectionId = DB.addConnectionInfo(locals.user.id, { + type: 'youtube-music', + serviceInfo: { userId: userChannel.id! }, + tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, + }) const response = await fetch(`/api/connections?ids=${newConnectionId}`, { method: 'GET', @@ -84,7 +68,7 @@ export const actions: Actions = { const formData = await request.formData() const connectionId = formData.get('connectionId')!.toString() - Connections.deleteConnection(connectionId) + DB.deleteConnectionInfo(connectionId) return { deletedConnectionId: connectionId } }, diff --git a/src/routes/settings/connections/+page.svelte b/src/routes/settings/connections/+page.svelte index b5174c7..eaf87d6 100644 --- a/src/routes/settings/connections/+page.svelte +++ b/src/routes/settings/connections/+page.svelte @@ -11,7 +11,7 @@ import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' export let data: PageServerData - let connections: Connection[] = data.connections + let connections: ConnectionInfo[] = data.connections const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => { const { serverUrl, username, password } = Object.fromEntries(formData) @@ -34,7 +34,7 @@ if (result.type === 'failure') { return ($newestAlert = ['warning', result.data?.message]) } else if (result.type === 'success') { - const newConnection: Connection<'jellyfin'> = result.data!.newConnection + const newConnection: ConnectionInfo = result.data!.newConnection connections = [...connections, newConnection] newConnectionModal = null @@ -67,7 +67,7 @@ if (result.type === 'failure') { return ($newestAlert = ['warning', result.data?.message]) } else if (result.type === 'success') { - const newConnection: Connection<'youtube-music'> = result.data!.newConnection + const newConnection: ConnectionInfo = result.data!.newConnection connections = [...connections, newConnection] return ($newestAlert = ['success', 'Added Youtube Music']) } diff --git a/src/routes/settings/connections/connectionProfile.svelte b/src/routes/settings/connections/connectionProfile.svelte index e3118aa..e5bb118 100644 --- a/src/routes/settings/connections/connectionProfile.svelte +++ b/src/routes/settings/connections/connectionProfile.svelte @@ -6,7 +6,7 @@ import { fly } from 'svelte/transition' import { enhance } from '$app/forms' - export let connection: Connection + export let connection: ConnectionInfo export let submitFunction: SubmitFunction $: serviceData = Services[connection.type] @@ -14,16 +14,16 @@ let showModal = false const subHeaderItems: string[] = [] - if ('username' in connection.service && connection.service.username) subHeaderItems.push(connection.service.username) - if ('serverName' in connection.service && connection.service.serverName) subHeaderItems.push(connection.service.serverName) + if ('username' in connection.serviceInfo && connection.serviceInfo.username) subHeaderItems.push(connection.serviceInfo.username) + if ('serverName' in connection.serviceInfo && connection.serviceInfo.serverName) subHeaderItems.push(connection.serviceInfo.serverName)
{serviceData.displayName} icon - {#if 'profilePicture' in connection.service && connection.service.profilePicture} - + {#if 'profilePicture' in connection.serviceInfo && connection.serviceInfo.profilePicture} + {/if}