Connection management now id based rather than type based

This commit is contained in:
Eclypsed
2024-01-07 14:23:22 -05:00
parent 0da467d1e0
commit 4a43b06c72
10 changed files with 166 additions and 164 deletions

View File

@@ -4,6 +4,7 @@ Big Problems:
- Authentication for every other potential streaming service:
- YouTube Music:? https://ytmusicapi.readthedocs.io/en/stable/setup/oauth.html
- Spotify: https://developer.spotify.com/documentation/web-api/concepts/authorization
- Do I allow users to connect multiple accounts from the same service?
Little Problems:
- Video and audio need to be kept in sync, accounting for buffering and latency.

View File

@@ -0,0 +1,52 @@
{
"jellyfin": {
"displayName": "Jellyfin",
"type": ["streaming"],
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg"
},
"youtube-music": {
"displayName": "YouTube Music",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
},
"spotify": {
"displayName": "Spotify",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg"
},
"apple-music": {
"displayName": "Apple Music",
"type": ["streaming", "marketplace"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/5f/Apple_Music_icon.svg"
},
"bandcamp": {
"displayName": "bandcamp",
"type": ["marketplace", "streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Bandcamp-button-bc-circle-aqua.svg"
},
"soundcloud": {
"displayName": "SoundCloud",
"type": ["streaming"],
"icon": "https://www.vectorlogo.zone/logos/soundcloud/soundcloud-icon.svg"
},
"lastfm": {
"displayName": "Last.fm",
"type": ["analytics"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/c/c4/Lastfm.svg"
},
"plex": {
"displayName": "Plex",
"type": ["streaming"],
"icon": "https://www.vectorlogo.zone/logos/plextv/plextv-icon.svg"
},
"deezer": {
"displayName": "deezer",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6b/Deezer_Icon.svg"
},
"amazon-music": {
"displayName": "Amazon Music",
"type": ["streaming", "marketplace"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/92/Amazon_Music_logo.svg"
}
}

Binary file not shown.

View File

@@ -5,10 +5,7 @@ const db = new Database('./src/lib/server/db/users.db', { verbose: console.info
db.pragma('foreign_keys = ON')
const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)'
const initUserConnectionsTable =
'CREATE TABLE IF NOT EXISTS UserConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL, serviceName VARCHAR(64) NOT NULL, accessToken TEXT, refreshToken TEXT, expiry DATETIME, FOREIGN KEY(userId) REFERENCES Users(id))'
const initJellyfinAuthTable = 'CREATE TABLE IF NOT EXISTS JellyfinConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, accesstoken TEXT, serverid TEXT)'
const initYouTubeMusicConnectionsTable = ''
const initSpotifyConnectionsTable = ''
'CREATE TABLE IF NOT EXISTS UserConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL, serviceType VARCHAR(64) NOT NULL, accessToken TEXT, refreshToken TEXT, expiry INTEGER, connectionInfo TEXT, FOREIGN KEY(userId) REFERENCES Users(id))'
db.exec(initUsersTable)
db.exec(initUserConnectionsTable)
@@ -30,41 +27,29 @@ export class Users {
export class UserConnections {
static validServices = Object.keys(Services)
static getConnections = (userId, serviceNames = null) => {
if (!serviceNames) {
const connections = db.prepare('SELECT * FROM UserConnections WHERE userId = ?').all(userId)
if (connections.length === 0) return null
return connections
}
if (!Array.isArray(serviceNames)) {
if (typeof serviceNames !== 'string') throw new Error('Service names must be a string or array of strings')
serviceNames = [serviceNames]
}
serviceNames = serviceNames.filter((service) => this.validServices.includes(service))
const placeholders = serviceNames.map(() => '?').join(', ') // This is SQL-injection safe, the placeholders are just ?, ?, ?....
const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ? AND serviceName IN (${placeholders})`).all(userId, ...serviceNames)
static getConnections = (userId) => {
const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ?`).all(userId)
if (connections.length === 0) return null
return connections
}
// May want to give accessToken a default of null in the future if one of the services does not use access tokens
static setConnection = (userId, serviceName, accessToken, refreshToken = null, expiry = null) => {
if (!this.validServices.includes(serviceName)) throw new Error(`Service name ${serviceName} is invalid`)
static addConnection = (userId, serviceType, accessToken, options = {}) => {
const { refreshToken = null, expiry = null, connectionInfo = null } = options
const existingConnection = this.getConnections(userId, serviceName)
if (existingConnection) {
db.prepare('UPDATE UserConnections SET accessToken = ?, refreshToken = ?, expiry = ? WHERE userId = ? AND serviceName = ?').run(accessToken, refreshToken, expiry, userId, serviceName)
} else {
db.prepare('INSERT INTO UserConnections(userId, serviceName, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?)').run(userId, serviceName, accessToken, refreshToken, expiry)
}
// return this.getConnections(userId, serviceName) <--- Uncomment this if want to return new connection data after update
if (!this.validServices.includes(serviceType)) throw new Error(`Service name ${serviceType} is invalid`)
if (connectionInfo) JSON.parse(connectionInfo) // Aditional validation, if connectionInfo is not stringified valid json it will throw an error
const commandInfo = db
.prepare('INSERT INTO UserConnections(userId, serviceType, accessToken, refreshToken, expiry, connectionInfo) VALUES(?, ?, ?, ?, ?, ?)')
.run(userId, serviceType, accessToken, refreshToken, expiry, connectionInfo)
return commandInfo.lastInsertRowid
}
static deleteConnection = (userId, serviceName) => {
const info = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND serviceName = ?').run(userId, serviceName)
if (!info.changes === 0) throw new Error(`User does not have connection: ${serviceName}`)
static deleteConnection = (userId, serviceId) => {
const commandInfo = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND id = ?').run(userId, serviceId)
if (!commandInfo.changes === 0) throw new Error(`User does not have connection with id: ${serviceId}`)
return serviceId
}
}

View File

@@ -8,10 +8,5 @@
"displayName": "YouTube Music",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
},
"spotify": {
"displayName": "Spotify",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg"
}
}

View File

@@ -29,7 +29,7 @@ export async function GET({ url, fetch }) {
if (!authResponse.headers.get('content-type').includes('application/json')) return new Response('Jellyfin server returned invalid data', { status: 500 })
const data = await authResponse.json()
const requiredData = ['User', 'SessionInfo', 'AccessToken', 'ServerId']
const requiredData = ['User', 'AccessToken', 'ServerId']
if (!requiredData.every((key) => Object.keys(data).includes(key))) return new Response('Data missing from Jellyfin server response', { status: 500 })

View File

@@ -30,8 +30,11 @@ export async function PATCH({ request }) {
const schema = Joi.object({
userId: Joi.number().required(),
connection: Joi.object({
serviceName: Joi.string().required(),
serviceType: Joi.string().required(),
accessToken: Joi.string().required(),
refreshToken: Joi.string(),
expiry: Joi.number(),
connectionInfo: Joi.string(),
}).required(),
})
@@ -41,9 +44,10 @@ export async function PATCH({ request }) {
const validation = schema.validate({ userId, connection })
if (validation.error) return new Response(validation.error.message, { status: 400 })
UserConnections.setConnection(userId, connection.serviceName, connection.accessToken)
const { serviceType, accessToken, refreshToken, expiry, connectionInfo } = connection
const newConnectionId = UserConnections.addConnection(userId, serviceType, accessToken, { refreshToken, expiry, connectionInfo })
return new Response('Updated Connection')
return new Response(JSON.stringify({ id: newConnectionId }))
}
/** @type {import('./$types').RequestHandler} */
@@ -51,7 +55,7 @@ export async function DELETE({ request }) {
const schema = Joi.object({
userId: Joi.number().required(),
connection: Joi.object({
serviceName: Joi.string().required(),
serviceId: Joi.string().required(),
}).required(),
})
@@ -61,7 +65,7 @@ export async function DELETE({ request }) {
const validation = schema.validate({ userId, connection })
if (validation.error) return new Response(validation.error.message, { status: 400 })
UserConnections.deleteConnection(userId, connection.serviceName)
const deletedConnectionId = UserConnections.deleteConnection(userId, connection.serviceId)
return new Response('Deleted Connection')
return new Response(JSON.stringify({ id: deletedConnectionId }))
}

View File

@@ -10,11 +10,16 @@ export const load = async ({ fetch, locals }) => {
},
})
if (response.ok) {
const connectionsData = await response.json()
if (connectionsData) {
const serviceNames = connectionsData.map((connection) => connection.serviceName)
return { existingConnections: serviceNames }
const connectionData = await response.json()
const clientConnectionData = {}
connectionData?.forEach((connection) => {
const { id, serviceType, connectionInfo } = connection
clientConnectionData[id] = {
serviceType,
connectionInfo: JSON.parse(connectionInfo),
}
})
return { existingConnections: clientConnectionData }
} else {
const error = await response.text()
console.log(error)
@@ -42,23 +47,26 @@ export const actions = {
}
const jellyfinAuthData = await jellyfinAuthResponse.json()
const jellyfinAccessToken = jellyfinAuthData.AccessToken
const { User, AccessToken, ServerId } = jellyfinAuthData
const connectionInfo = JSON.stringify({ User, ServerId })
const updateConnectionsResponse = await fetch('/api/user/connections', {
method: 'PATCH',
headers: {
apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId,
},
body: JSON.stringify({ serviceName: 'jellyfin', accessToken: jellyfinAccessToken }),
body: JSON.stringify({ serviceType: 'jellyfin', accessToken: AccessToken, connectionInfo }),
})
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { message: 'Updated Jellyfin connection' }
const newConnectionData = await updateConnectionsResponse.json()
return { message: 'Added Jellyfin connection', newConnection: { id: newConnectionData.id, serviceType: 'jellyfin', connectionInfo: JSON.parse(connectionInfo) } }
},
deleteConnection: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const serviceName = formData.get('service')
const serviceId = formData.get('serviceId')
const deleteConnectionResponse = await fetch('/api/user/connections', {
method: 'DELETE',
@@ -66,11 +74,13 @@ export const actions = {
apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId,
},
body: JSON.stringify({ serviceName }),
body: JSON.stringify({ serviceId }),
})
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { message: 'Connection deleted' }
const deletedConnectionData = await deleteConnectionResponse.json()
return { message: 'Connection deleted', deletedConnection: { id: deletedConnectionData.id } }
},
}

View File

@@ -7,67 +7,10 @@
import { newestAlert } from '$lib/stores/alertStore.js'
import IconButton from '$lib/components/utility/iconButton.svelte'
import Toggle from '$lib/components/utility/toggle.svelte'
import { onMount } from 'svelte'
export let data
let existingConnections = data?.existingConnections
let existingConnections = data.existingConnections
const testServices = {
jellyfin: {
displayName: 'Jellyfin',
type: ['streaming'],
icon: 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg',
},
'youtube-music': {
displayName: 'YouTube Music',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg',
},
spotify: {
displayName: 'Spotify',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg',
},
'apple-music': {
displayName: 'Apple Music',
type: ['streaming', 'marketplace'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/5/5f/Apple_Music_icon.svg',
},
bandcamp: {
displayName: 'bandcamp',
type: ['marketplace', 'streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Bandcamp-button-bc-circle-aqua.svg',
},
soundcloud: {
displayName: 'SoundCloud',
type: ['streaming'],
icon: 'https://www.vectorlogo.zone/logos/soundcloud/soundcloud-icon.svg',
},
lastfm: {
displayName: 'Last.fm',
type: ['analytics'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/c/c4/Lastfm.svg',
},
plex: {
displayName: 'Plex',
type: ['streaming'],
icon: 'https://www.vectorlogo.zone/logos/plextv/plextv-icon.svg',
},
deezer: {
displayName: 'deezer',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6b/Deezer_Icon.svg',
},
'amazon-music': {
displayName: 'Amazon Music',
type: ['streaming', 'marketplace'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/9/92/Amazon_Music_logo.svg',
},
}
const serviceAuthenticationMethods = {}
let formMode = null
const submitCredentials = ({ formData, action, cancel }) => {
switch (action.search) {
case '?/authenticateJellyfin':
@@ -88,9 +31,6 @@
formData.append('deviceId', deviceId)
break
case '?/deleteConnection':
const connection = formData.get('service')
$newestAlert = ['info', `Delete ${connection}`]
cancel()
break
default:
cancel()
@@ -101,7 +41,19 @@
case 'failure':
return ($newestAlert = ['warning', result.data.message])
case 'success':
formMode = null
modal = null
if (result.data?.newConnection) {
const { id, serviceType, connectionInfo } = result.data.newConnection
existingConnections[id] = {
serviceType,
connectionInfo,
}
existingConnections = existingConnections
} else if (result.data?.deletedConnection) {
const id = result.data.deletedConnection.id
delete existingConnections[id]
existingConnections = existingConnections
}
return ($newestAlert = ['success', result.data.message])
}
}
@@ -110,32 +62,39 @@
let modal
</script>
<main class="h-full">
<main>
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
<h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4">
{#each Object.entries(testServices) as [serviceType, serviceData]}
{#if !existingConnections.includes(serviceType)}
<button class="bg-ne h-14 rounded-md" style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));" on:click={() => (modal = JellyfinAuthBox)}>
{#each Object.entries(Services) as [serviceType, serviceData]}
<button
class="bg-ne h-14 rounded-md"
style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));"
on:click={() => {
if (serviceType === 'jellyfin') modal = JellyfinAuthBox
}}
>
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" />
</button>
{/if}
{/each}
</div>
</section>
{#if existingConnections}
<div class="grid gap-8">
{#each existingConnections as connectionType}
{@const service = Services[connectionType]}
<section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" in:fly={{ duration: 300, x: 50 }}>
{#each Object.entries(existingConnections) as [connectionId, connectionData]}
{@const serviceData = Services[connectionData.serviceType]}
<section class="overflow-hidden 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">
<img src={service.icon} alt="{service.displayName} icon" class="aspect-square h-full p-1" />
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-1" />
<div>
{#if connectionData.serviceType === 'jellyfin'}
<div>{connectionData.connectionInfo.User.Name}</div>
{:else}
<div>Account Name</div>
<div class="text-sm text-neutral-500">{service.displayName}</div>
{/if}
<div class="text-sm text-neutral-500">{serviceData.displayName}</div>
</div>
<div class="ml-auto h-8">
<IconButton on:click={() => (modal = `delete-${connectionType}`)}>
<IconButton on:click={() => (modal = `delete-${connectionId}`)}>
<i slot="icon" class="fa-solid fa-link-slash" />
</IconButton>
</div>
@@ -150,17 +109,16 @@
</section>
{/each}
</div>
{/if}
{#if modal}
<form method="post" use:enhance={submitCredentials} transition:fly={{ duration: 300, y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<form method="post" use:enhance={submitCredentials} transition:fly={{ y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{#if typeof modal === 'string'}
{@const connectionType = modal.replace('delete-', '')}
{@const service = Services[connectionType]}
{@const connectionId = modal.replace('delete-', '')}
{@const service = Services[existingConnections[connectionId].serviceType]}
<div class="rounded-lg bg-neutral-900 p-5">
<h1 class="pb-4 text-center">Delete {service.displayName}?</h1>
<h1 class="pb-4 text-center">Delete {service.displayName} connection?</h1>
<div class="flex w-60 justify-around">
<input type="hidden" name="service" value={connectionType} />
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click={() => (modal = null)}>Cancel</button>
<input type="hidden" name="serviceId" value={connectionId} />
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click|preventDefault={() => (modal = null)}>Cancel</button>
<button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button>
</div>
</div>

View File

@@ -2,23 +2,20 @@
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
const dispatchClose = () => {
dispatch('close')
}
</script>
<div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-900 px-8">
<h1 class="text-center text-4xl">Jellyfin Sign In</h1>
<div class="flex w-full flex-col gap-5">
<input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" />
<input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
<div class="flex w-full flex-row gap-4">
<input type="text" name="username" autocomplete="off" placeholder="Username" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" />
<input type="password" name="password" placeholder="Password" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" />
<input type="text" name="username" autocomplete="off" placeholder="Username" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
<input type="password" name="password" placeholder="Password" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
</div>
</div>
<div class="flex items-center justify-around text-lg">
<button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-[97%]" on:click|preventDefault={dispatchClose}>Cancel</button>
<button id="submit-button" type="submit" class="bg-jellyfin-blue w-1/3 rounded py-2 transition-all active:scale-[97%]" formaction="?/authenticateJellyfin">Submit</button>
<button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-[97%]" on:click|preventDefault={() => dispatch('close')}>Cancel</button>
<button id="submit-button" type="submit" class="w-1/3 rounded bg-jellyfin-blue py-2 transition-all active:scale-[97%]" formaction="?/authenticateJellyfin">Submit</button>
</div>
</div>