Going to try out some OOP/DI patterns and see where that takes me
This commit is contained in:
42
README.md
42
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
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
118
src/app.d.ts
vendored
118
src/app.d.ts
vendored
@@ -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 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.
|
||||
// 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,102 +109,30 @@ 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
|
||||
userId: string
|
||||
type: 'jellyfin'
|
||||
service: {
|
||||
type SerivceInfo = {
|
||||
userId: string
|
||||
urlOrigin: string
|
||||
username?: string
|
||||
serverName?: string
|
||||
}
|
||||
tokens: {
|
||||
|
||||
type Tokens = {
|
||||
accessToken: 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'
|
||||
}
|
||||
}
|
||||
|
||||
namespace YouTubeMusic {
|
||||
interface Connection {
|
||||
id: string
|
||||
userId: string
|
||||
type: 'youtube-music'
|
||||
service: {
|
||||
type SerivceInfo = {
|
||||
userId: string
|
||||
username?: string
|
||||
profilePicture?: string
|
||||
}
|
||||
tokens: {
|
||||
|
||||
type Tokens = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiry: number
|
||||
}
|
||||
}
|
||||
|
||||
interface HomeItems {
|
||||
listenAgain: MediaItem[]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
{#if checkSongOrAlbum(mediaItem) && 'artists' in mediaItem && mediaItem.artists}
|
||||
{#each mediaItem.artists as artist}
|
||||
{@const listIndex = mediaItem.artists.indexOf(artist)}
|
||||
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connectionId}">{artist.name}</a>
|
||||
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
|
||||
{#if listIndex < mediaItem.artists.length - 1}
|
||||
<span class="mr-0.5 text-sm">,</span>
|
||||
{/if}
|
||||
|
||||
30
src/lib/server/connections.ts
Normal file
30
src/lib/server/connections.ts
Normal 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
123
src/lib/server/db.ts
Normal 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
199
src/lib/server/jellyfin.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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 => {}
|
||||
}
|
||||
429
src/lib/server/youtube-music.ts
Normal file
429
src/lib/server/youtube-music.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
192
src/lib/service-managers/youtube-music-types.d.ts
vendored
192
src/lib/service-managers/youtube-music-types.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<serviceType>[] = []
|
||||
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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<serviceType>
|
||||
|
||||
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 })
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||
|
||||
export let data: PageServerData
|
||||
let connections: Connection<serviceType>[] = 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'])
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { fly } from 'svelte/transition'
|
||||
import { enhance } from '$app/forms'
|
||||
|
||||
export let connection: Connection<serviceType>
|
||||
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)
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<div class="relative aspect-square h-full p-1">
|
||||
<img src={serviceData.icon} alt="{serviceData.displayName} icon" />
|
||||
{#if 'profilePicture' in connection.service && connection.service.profilePicture}
|
||||
<img src={connection.service.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
|
||||
{#if 'profilePicture' in connection.serviceInfo && connection.serviceInfo.profilePicture}
|
||||
<img src={connection.serviceInfo.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user