FINALLY, Beautiful connection types!

This commit is contained in:
Eclypsed
2024-02-23 00:53:54 -05:00
parent c2236ab8ac
commit c7b9b214b7
16 changed files with 200 additions and 214 deletions

39
src/app.d.ts vendored
View File

@@ -4,7 +4,7 @@ declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
interface Locals { interface Locals {
user: Omit<User, 'password'> user: Omit<User, 'passwordHash'>
} }
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
@@ -21,25 +21,12 @@ declare global {
interface User { interface User {
id: string id: string
username: string username: string
password: string passwordHash: string
} }
type serviceType = 'jellyfin' | 'youtube-music' type serviceType = 'jellyfin' | 'youtube-music'
type Service = Jellyfin.Service | YouTubeMusic.Service type Connection<T extends serviceType> = T extends 'jellyfin' ? Jellyfin.Connection : T extends 'youtube-music' ? YouTubeMusic.Connection : never
type Tokens<T> = T extends Jellyfin.Service ? Jellyfin.Tokens : T extends YouTubeMusic.Service ? YouTubeMusic.Tokens : {}
// type ServiceTokenPair = [Jellyfin.Service, Jellyfin.Tokens] | [YouTubeMusic.Service, YouTubeMusic.Tokens]
interface BaseConnection<T> {
id: string
userId: string
type: T extends Jellyfin.Service ? 'jellyfin' : T extends YouTubeMusic.Service ? 'youtube-music' : serviceType
service: T extends undefined ? Service : T
}
type Connection<T extends Service = undefined> = BaseConnection<T> & Tokens<T>
// These Schemas should only contain general info data that is necessary for data fetching purposes. // These Schemas should only contain general info data that is necessary for data fetching purposes.
// They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type. // They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type.
@@ -94,16 +81,20 @@ declare global {
// The jellyfin API will not always return the data it says it will, for example /Users/AuthenticateByName says it will // The jellyfin API will not always return the data it says it will, for example /Users/AuthenticateByName says it will
// retrun the ServerName, it wont. This must be fetched from /System/Info. // retrun the ServerName, it wont. This must be fetched from /System/Info.
// So, ONLY DEFINE THE INTERFACES FOR DATA THAT IS GARUNTEED TO BE RETURNED (unless the data value itself is inherently optional) // So, ONLY DEFINE THE INTERFACES FOR DATA THAT IS GARUNTEED TO BE RETURNED (unless the data value itself is inherently optional)
interface Service { interface Connection {
id: string
userId: string
type: 'jellyfin'
service: {
userId: string userId: string
urlOrigin: string urlOrigin: string
username?: string username?: string
serverName?: string serverName?: string
} }
tokens: {
interface Tokens {
accessToken: string accessToken: string
} }
}
interface User { interface User {
Name: string Name: string
@@ -171,18 +162,22 @@ declare global {
} }
namespace YouTubeMusic { namespace YouTubeMusic {
interface Service { interface Connection {
id: string
userId: string
type: 'youtube-music'
service: {
userId: string userId: string
username?: string username?: string
profilePicture?: string profilePicture?: string
} }
tokens: {
interface Tokens {
accessToken: string accessToken: string
refreshToken: string refreshToken: string
expiry: number expiry: number
} }
} }
}
} }
export {} export {}

View File

@@ -17,7 +17,7 @@ export const handle: Handle = async ({ event, resolve }) => {
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`) if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
try { try {
const tokenData = jwt.verify(authToken, SECRET_JWT_KEY) as Omit<User, 'password'> const tokenData = jwt.verify(authToken, SECRET_JWT_KEY) as Omit<User, 'passwordHash'>
event.locals.user = tokenData event.locals.user = tokenData
} catch { } catch {
throw redirect(303, `/login?redirect=${urlpath}`) throw redirect(303, `/login?redirect=${urlpath}`)
@@ -33,7 +33,7 @@ export const handleFetch: HandleFetch = async ({ request, fetch, event }) => {
if (event.locals.user) { if (event.locals.user) {
const expiredConnection = Connections.getExpiredConnections(event.locals.user.id) const expiredConnection = Connections.getExpiredConnections(event.locals.user.id)
for (const connection of expiredConnection) { for (const connection of expiredConnection) {
switch (connection.service.type) { switch (connection.type) {
case 'youtube-music': case 'youtube-music':
// Again DON'T SHIP THIS, CLIENT SECRET SHOULD NOT BE EXPOSED TO USERS // Again DON'T SHIP THIS, CLIENT SECRET SHOULD NOT BE EXPOSED TO USERS
const response = await fetch('https://oauth2.googleapis.com/token', { const response = await fetch('https://oauth2.googleapis.com/token', {
@@ -41,13 +41,13 @@ export const handleFetch: HandleFetch = async ({ request, fetch, event }) => {
body: JSON.stringify({ body: JSON.stringify({
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
client_secret: YOUTUBE_API_CLIENT_SECRET, client_secret: YOUTUBE_API_CLIENT_SECRET,
refresh_token: connection.refreshToken as string, refresh_token: connection.tokens.refreshToken as string,
grant_type: 'refresh_token', grant_type: 'refresh_token',
}), }),
}) })
const { access_token, expires_in } = await response.json() const { access_token, expires_in } = await response.json()
const newExpiry = Date.now() + expires_in * 1000 const newExpiry = Date.now() + expires_in * 1000
Connections.updateTokens(connection.id, access_token, connection.refreshToken, newExpiry) Connections.updateTokens(connection.id, access_token, connection.tokens.refreshToken, newExpiry)
console.log('Refreshed YouTubeMusic access token') console.log('Refreshed YouTubeMusic access token')
} }
} }

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
export let mediaItem: MediaItem export let mediaItem: MediaItem
import Services from '$lib/services.json'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'

Binary file not shown.

View File

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

View File

@@ -1,3 +0,0 @@
import { google } from 'googleapis'
export class YouTubeMusic {}

View File

@@ -1,14 +0,0 @@
{
"jellyfin": {
"displayName": "Jellyfin",
"type": ["streaming"],
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg",
"primaryColor": "--jellyfin-blue"
},
"youtube-music": {
"displayName": "YouTube Music",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg",
"primaryColor": "--youtube-red"
}
}

View File

@@ -1,3 +1,20 @@
import { google } from 'googleapis'
export const serviceData = {
jellyfin: {
displayName: 'Jellyfin',
type: ['streaming'],
icon: 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg',
primaryColor: '--jellyfin-blue',
},
'youtube-music': {
displayName: 'YouTube Music',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg',
primaryColor: '--youtube-red',
},
}
export class Jellyfin { export class Jellyfin {
static audioPresets = (userId: string) => { static audioPresets = (userId: string) => {
return { return {
@@ -10,8 +27,28 @@ export class Jellyfin {
} }
} }
static songFactory = (song: Jellyfin.Song, connection: Connection): Song => { static fetchSerivceInfo = async (userId: string, urlOrigin: string, accessToken: string): Promise<Connection<'jellyfin'>['service']> => {
const { id, service } = connection 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 const thumbnail = song.ImageTags?.Primary
? new URL(`Items/${song.Id}/Images/Primary`, service.urlOrigin).href ? new URL(`Items/${song.Id}/Images/Primary`, service.urlOrigin).href
: song.AlbumPrimaryImageTag : song.AlbumPrimaryImageTag
@@ -29,7 +66,7 @@ export class Jellyfin {
return { return {
connectionId: id, connectionId: id,
serviceType: service.type, serviceType: type,
type: 'song', type: 'song',
id: song.Id, id: song.Id,
name: song.Name, name: song.Name,
@@ -42,8 +79,8 @@ export class Jellyfin {
} }
} }
static albumFactory = (album: Jellyfin.Album, connection: Connection): Album => { static albumFactory = (album: Jellyfin.Album, connection: Connection<'jellyfin'>): Album => {
const { id, service } = connection const { id, type, service } = connection
const thumbnail = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, service.urlOrigin).href : undefined const thumbnail = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, service.urlOrigin).href : undefined
const albumArtists = album.AlbumArtists const albumArtists = album.AlbumArtists
@@ -60,7 +97,7 @@ export class Jellyfin {
return { return {
connectionId: id, connectionId: id,
serviceType: service.type, serviceType: type,
type: 'album', type: 'album',
id: album.Id, id: album.Id,
name: album.Name, name: album.Name,
@@ -72,13 +109,13 @@ export class Jellyfin {
} }
} }
static playListFactory = (playlist: Jellyfin.Playlist, connection: Connection): Playlist => { static playListFactory = (playlist: Jellyfin.Playlist, connection: Connection<'jellyfin'>): Playlist => {
const { id, service } = connection const { id, type, service } = connection
const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, service.urlOrigin).href : undefined const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, service.urlOrigin).href : undefined
return { return {
connectionId: id, connectionId: id,
serviceType: service.type, serviceType: type,
type: 'playlist', type: 'playlist',
id: playlist.Id, id: playlist.Id,
name: playlist.Name, name: playlist.Name,
@@ -87,13 +124,13 @@ export class Jellyfin {
} }
} }
static artistFactory = (artist: Jellyfin.Artist, connection: Connection): Artist => { static artistFactory = (artist: Jellyfin.Artist, connection: Connection<'jellyfin'>): Artist => {
const { id, service } = connection const { id, type, service } = connection
const thumbnail = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, service.urlOrigin).href : undefined const thumbnail = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, service.urlOrigin).href : undefined
return { return {
connectionId: id, connectionId: id,
serviceType: service.type, serviceType: type,
type: 'artist', type: 'artist',
id: artist.Id, id: artist.Id,
name: artist.Name, name: artist.Name,
@@ -101,3 +138,17 @@ export class Jellyfin {
} }
} }
} }
export class YouTubeMusic {
static fetchServiceInfo = async (userId: string, accessToken: string): Promise<Connection<'youtube-music'>['service']> => {
const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: accessToken })
const userChannel = userChannelResponse.data.items![0]
return {
userId,
username: userChannel.snippet?.title as string,
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
}
}
}

View File

@@ -1,12 +1,23 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Jellyfin, YouTubeMusic } from '$lib/services'
import { Connections } from '$lib/server/users' import { Connections } from '$lib/server/users'
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',') const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',')
if (!ids) return new Response('Missing ids query parameter', { status: 400 }) if (!ids) return new Response('Missing ids query parameter', { status: 400 })
const connections: Connection[] = [] const connections: Connection<serviceType>[] = []
for (const connectionId of ids) connections.push(Connections.getConnection(connectionId)) 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':
connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken)
break
}
}
return Response.json({ connections }) return Response.json({ connections })
} }

View File

@@ -1,53 +0,0 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/users'
import { google } from 'googleapis'
const youtubeInfo = async (connection: Connection): Promise<ConnectionInfo> => {
const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: connection.accessToken })
const userChannel = userChannelResponse.data.items![0]
return {
connectionId: connection.id,
serviceType: connection.service.type,
username: userChannel.snippet?.title as string,
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
}
}
const jellyfinInfo = async (connection: Connection): Promise<ConnectionInfo> => {
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${connection.accessToken}"` })
const userUrl = new URL(`Users/${connection.service.userId}`, connection.service.urlOrigin).href
const systemUrl = new URL('System/Info', connection.service.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 {
connectionId: connection.id,
serviceType: 'jellyfin',
username: userData.Name,
serverName: systemData.ServerName,
}
}
export const GET: RequestHandler = async ({ params }) => {
const connectionId = params.connectionId!
const connection = Connections.getConnection(connectionId)
let info: ConnectionInfo
switch (connection.service.type) {
case 'jellyfin':
info = await jellyfinInfo(connection)
break
case 'youtube-music':
info = await youtubeInfo(connection)
break
}
return Response.json({ info })
}

View File

@@ -1,9 +1,21 @@
import { Connections } from '$lib/server/users' import { Connections } from '$lib/server/users'
import { Jellyfin, YouTubeMusic } from '$lib/services'
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userId = params.userId!
const connections = Connections.getUserConnections(userId) const connections = 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':
connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken)
break
}
}
return Response.json({ connections }) return Response.json({ connections })
} }

View File

@@ -1,6 +1,6 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private' import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import { Jellyfin } from '$lib/service-managers/jellyfin' import { Jellyfin } from '$lib/services'
// This is temporary functionally for the sake of developing the app. // This is temporary functionally for the sake of developing the app.
// In the future will implement more robust algorithm for offering recommendations // In the future will implement more robust algorithm for offering recommendations
@@ -13,9 +13,9 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
const recommendations: MediaItem[] = [] const recommendations: MediaItem[] = []
for (const connection of userConnections.connections) { for (const connection of userConnections.connections) {
const { service, accessToken } = connection as Connection const { type, service, tokens } = connection as Connection<serviceType>
switch (service.type) { switch (type) {
case 'jellyfin': case 'jellyfin':
const mostPlayedSongsSearchParams = new URLSearchParams({ const mostPlayedSongsSearchParams = new URLSearchParams({
SortBy: 'PlayCount', SortBy: 'PlayCount',
@@ -26,7 +26,7 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
}) })
const mostPlayedSongsURL = new URL(`/Users/${service.userId}/Items?${mostPlayedSongsSearchParams.toString()}`, service.urlOrigin).href const mostPlayedSongsURL = new URL(`/Users/${service.userId}/Items?${mostPlayedSongsSearchParams.toString()}`, service.urlOrigin).href
const requestHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` }) const requestHeaders = new Headers({ Authorization: `MediaBrowser Token="${tokens.accessToken}"` })
const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: requestHeaders }) const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: requestHeaders })
const mostPlayedData = await mostPlayedResponse.json() const mostPlayedData = await mostPlayedResponse.json()

View File

@@ -18,7 +18,7 @@ export const actions: Actions = {
const user = Users.getUsername(username.toString()) const user = Users.getUsername(username.toString())
if (!user) return fail(400, { message: 'Invalid Username' }) if (!user) return fail(400, { message: 'Invalid Username' })
const passwordValid = await compare(password.toString(), user.password) const passwordValid = await compare(password.toString(), user.passwordHash)
if (!passwordValid) return fail(400, { message: 'Invalid Password' }) if (!passwordValid) return fail(400, { message: 'Invalid Password' })
const authToken = jwt.sign({ id: user.id, username: user.username }, SECRET_JWT_KEY, { expiresIn: '100d' }) const authToken = jwt.sign({ id: user.id, username: user.username }, SECRET_JWT_KEY, { expiresIn: '100d' })

View File

@@ -43,13 +43,11 @@ export const actions: Actions = {
return fail(400, { message: 'Could not reach Jellyfin server' }) return fail(400, { message: 'Could not reach Jellyfin server' })
} }
const serviceData: Service = { const newConnection = Connections.addConnection('jellyfin', {
type: 'jellyfin', userId: locals.user.id,
userId: authData.User.Id, service: { userId: authData.User.Id, urlOrigin: serverUrl.toString() },
urlOrigin: serverUrl.toString(), tokens: { accessToken: authData.AccessToken },
} })
const newConnection = Connections.addConnection(locals.user.id, serviceData, authData.AccessToken)
return { newConnection } return { newConnection }
}, },
@@ -63,13 +61,11 @@ export const actions: Actions = {
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! }) const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! })
const userChannel = userChannelResponse.data.items![0] const userChannel = userChannelResponse.data.items![0]
const serviceData: Service = { const newConnection = Connections.addConnection('youtube-music', {
type: 'youtube-music', userId: locals.user.id,
userId: userChannel.id!, service: { userId: userChannel.id! },
urlOrigin: 'https://www.googleapis.com/youtube/v3', tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
} })
const newConnection = Connections.addConnection(locals.user.id, serviceData, tokens.access_token!, tokens.refresh_token!, tokens.expiry_date!)
return { newConnection } return { newConnection }
}, },

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import Services from '$lib/services.json' import { serviceData } from '$lib/services'
import JellyfinAuthBox from './jellyfinAuthBox.svelte' import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/stores.js' import { newestAlert } from '$lib/stores.js'
import type { PageServerData } from './$types.js' import type { PageServerData } from './$types.js'
@@ -11,7 +11,7 @@
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
export let data: PageServerData export let data: PageServerData
let connections: Connection[] = data.connections let connections: Connection<serviceType>[] = data.connections
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => { const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
const { serverUrl, username, password } = Object.fromEntries(formData) const { serverUrl, username, password } = Object.fromEntries(formData)
@@ -34,11 +34,11 @@
if (result.type === 'failure') { if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message]) return ($newestAlert = ['warning', result.data?.message])
} else if (result.type === 'success') { } else if (result.type === 'success') {
const newConnection: Connection = result.data!.newConnection const newConnection: Connection<'jellyfin'> = result.data!.newConnection
connections = [...connections, newConnection] connections = [...connections, newConnection]
newConnectionModal = null newConnectionModal = null
return ($newestAlert = ['success', `Added ${Services[newConnection.service.type].displayName}`]) return ($newestAlert = ['success', `Added ${serviceData[newConnection.type].displayName}`])
} }
} }
} }
@@ -67,7 +67,7 @@
if (result.type === 'failure') { if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message]) return ($newestAlert = ['warning', result.data?.message])
} else if (result.type === 'success') { } else if (result.type === 'success') {
const newConnection: Connection = result.data!.newConnection const newConnection: Connection<'youtube-music'> = result.data!.newConnection
connections = [...connections, newConnection] connections = [...connections, newConnection]
return ($newestAlert = ['success', 'Added Youtube Music']) return ($newestAlert = ['success', 'Added Youtube Music'])
} }
@@ -84,12 +84,12 @@
} else if (result.type === 'success') { } else if (result.type === 'success') {
const id = result.data!.deletedConnectionId const id = result.data!.deletedConnectionId
const indexToDelete = connections.findIndex((connection) => connection.id === id) const indexToDelete = connections.findIndex((connection) => connection.id === id)
const serviceType = connections[indexToDelete].service.type const serviceType = connections[indexToDelete].type
connections.splice(indexToDelete, 1) connections.splice(indexToDelete, 1)
connections = connections connections = connections
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`]) return ($newestAlert = ['success', `Deleted ${serviceData[serviceType].displayName}`])
} }
} }
} }
@@ -102,11 +102,11 @@
<h1 class="py-2 text-xl">Add Connection</h1> <h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4"> <div class="flex flex-wrap gap-2 pb-4">
<button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}> <button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
<img src={Services.jellyfin.icon} alt="{Services.jellyfin.displayName} icon" class="aspect-square h-full p-2" /> <img src={serviceData.jellyfin.icon} alt="{serviceData.jellyfin.displayName} icon" class="aspect-square h-full p-2" />
</button> </button>
<form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}> <form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}>
<button class="add-connection-button h-14 rounded-md"> <button class="add-connection-button h-14 rounded-md">
<img src={Services['youtube-music'].icon} alt="{Services['youtube-music'].displayName} icon" class="aspect-square h-full p-2" /> <img src={serviceData['youtube-music'].icon} alt="{serviceData['youtube-music'].displayName} icon" class="aspect-square h-full p-2" />
</button> </button>
</form> </form>
</div> </div>

View File

@@ -1,47 +1,43 @@
<script lang="ts"> <script lang="ts">
import Services from '$lib/services.json' import { serviceData } from '$lib/services'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import Toggle from '$lib/components/util/toggle.svelte' import Toggle from '$lib/components/util/toggle.svelte'
import type { SubmitFunction } from '@sveltejs/kit' import type { SubmitFunction } from '@sveltejs/kit'
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition'
import { enhance } from '$app/forms' import { enhance } from '$app/forms'
export let connection: Connection export let connection: Connection<serviceType>
export let submitFunction: SubmitFunction export let submitFunction: SubmitFunction
$: serviceData = Services[connection.service.type] $: reactiveServiceData = serviceData[connection.type]
let showModal = false let showModal = false
</script> </script>
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}> <section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
<header class="flex h-20 items-center gap-4 p-4"> <header class="flex h-20 items-center gap-4 p-4">
<div class="h-full aspect-square p-1 relative"> <div class="relative aspect-square h-full p-1">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" /> <img src={reactiveServiceData.icon} alt="{reactiveServiceData.displayName} icon" />
{#if 'profilePicture' in connection.service && typeof connection.service.profilePicture === 'string'} {#if 'profilePicture' in connection.service && typeof connection.service.profilePicture === 'string'}
<img src="{connection.service.profilePicture}" alt="" class="absolute h-5 aspect-square bottom-0 right-0 rounded-full"/> <img src={connection.service.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
{/if} {/if}
</div> </div>
<div> <div>
<div>Username</div> <div>Username</div>
<div class="text-sm text-neutral-500"> <div class="text-sm text-neutral-500">
{serviceData.displayName} {reactiveServiceData.displayName}
{#if 'serverName' in connection.service} {#if 'serverName' in connection.service}
- {connection.service.serverName} - {connection.service.serverName}
{/if} {/if}
</div> </div>
</div> </div>
<div class="relative ml-auto h-8 flex flex-row-reverse gap-2"> <div class="relative ml-auto flex h-8 flex-row-reverse gap-2">
<IconButton halo={true} on:click={() => showModal = !showModal}> <IconButton halo={true} on:click={() => (showModal = !showModal)}>
<i slot="icon" class="fa-solid fa-ellipsis-vertical text-xl text-neutral-500" /> <i slot="icon" class="fa-solid fa-ellipsis-vertical text-xl text-neutral-500" />
</IconButton> </IconButton>
{#if showModal} {#if showModal}
<form <form use:enhance={submitFunction} method="post" class="absolute right-0 top-full flex flex-col items-center justify-center gap-1 rounded-md bg-neutral-900 p-2 text-xs">
use:enhance={submitFunction} <button formaction="?/deleteConnection" class="whitespace-nowrap rounded-md px-3 py-2 hover:bg-neutral-800">
method="post"
class="absolute right-0 top-full flex flex-col items-center justify-center gap-1 rounded-md bg-neutral-900 p-2 text-xs"
>
<button formaction="?/deleteConnection" class="py-2 px-3 whitespace-nowrap hover:bg-neutral-800 rounded-md">
<i class="fa-solid fa-link-slash mr-1" /> <i class="fa-solid fa-link-slash mr-1" />
Delete Connection Delete Connection
</button> </button>