Created ConnectionProfile class to encapsulate connection data for display on connection page

This commit is contained in:
Eclypsed
2024-01-09 00:46:23 -05:00
parent 4a43b06c72
commit f9359ae300
11 changed files with 232 additions and 153 deletions

104
package-lock.json generated
View File

@@ -13,7 +13,6 @@
"better-sqlite3": "^9.1.1",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"youtubei.js": "^7.0.0",
"ytdl-core": "^4.11.5"
},
"devDependencies": {
@@ -410,6 +409,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz",
"integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==",
"dev": true,
"engines": {
"node": ">=14"
}
@@ -468,6 +468,18 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz",
"integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.0",
"@jridgewell/trace-mapping": "^0.3.9"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.15",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
@@ -666,6 +678,7 @@
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
"integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -944,6 +957,14 @@
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -1608,17 +1629,6 @@
"@types/estree": "*"
}
},
"node_modules/jintr": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-1.1.0.tgz",
"integrity": "sha512-Tu9wk3BpN2v+kb8yT6YBtue+/nbjeLFv4vvVC4PJ7oCidHKbifWhvORrAbQfxVIQZG+67am/mDagpiGSVtvrZg==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"dependencies": {
"acorn": "^8.8.0"
}
},
"node_modules/jiti": {
"version": "1.19.3",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.3.tgz",
@@ -2681,6 +2691,29 @@
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/source-map-support/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -2878,6 +2911,34 @@
"node": ">=10"
}
},
"node_modules/terser": {
"version": "5.26.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz",
"integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/terser/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -2941,11 +3002,6 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -2961,6 +3017,7 @@
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.26.5.tgz",
"integrity": "sha512-cSb4bPFd5qgR7qr2jYAi0hlX9n5YKK2ONKkLFkxl+v/9BvC0sOpZjBHDBSXc5lWAf5ty9oZdRXytBIHzgUcerw==",
"dev": true,
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
@@ -3113,19 +3170,6 @@
"node": ">= 14"
}
},
"node_modules/youtubei.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-7.0.0.tgz",
"integrity": "sha512-z87cv6AAjj0c98BkD0qTJvBDTF2DdT+FntJUjmi+vHY2EV+CepeYQAE/eLsdhGvCb6LrNBgGVwVUzXpHYi8NoA==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"dependencies": {
"jintr": "^1.1.0",
"tslib": "^2.5.0",
"undici": "^5.19.1"
}
},
"node_modules/ytdl-core": {
"version": "4.11.5",
"resolved": "https://registry.npmjs.org/ytdl-core/-/ytdl-core-4.11.5.tgz",

View File

@@ -26,7 +26,6 @@
"better-sqlite3": "^9.1.1",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"youtubei.js": "^7.0.0",
"ytdl-core": "^4.11.5"
}
}

Binary file not shown.

View File

@@ -5,7 +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, serviceType VARCHAR(64) NOT NULL, accessToken TEXT, refreshToken TEXT, expiry INTEGER, connectionInfo TEXT, FOREIGN KEY(userId) REFERENCES Users(id))'
'CREATE TABLE IF NOT EXISTS UserConnections(id VARCHAR(36) PRIMARY KEY, userId INTEGER NOT NULL, serviceType VARCHAR(64) NOT NULL, serviceUserId TEXT NOT NULL, serviceUrl TEXT NOT NULL, accessToken TEXT NOT NULL, refreshToken TEXT, expiry INTEGER, FOREIGN KEY(userId) REFERENCES Users(id))'
db.exec(initUsersTable)
db.exec(initUserConnectionsTable)
@@ -27,29 +27,38 @@ export class Users {
export class UserConnections {
static validServices = Object.keys(Services)
static getConnections = (userId) => {
static getConnection = (id) => {
return db.prepare(`SELECT * FROM UserConnections WHERE id = ?`).get(id)
}
static getUserConnections = (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 addConnection = (userId, serviceType, accessToken, options = {}) => {
const { refreshToken = null, expiry = null, connectionInfo = null } = options
static addConnection = (userId, serviceType, serviceUserId, serviceUrl, accessToken, additionalApiData = {}) => {
const { refreshToken = null, expiry = null } = additionalApiData
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
const connectionId = '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
db.prepare('INSERT INTO UserConnections(id, userId, serviceType, serviceUserId, serviceUrl, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?, ?, ?, ?)').run(
connectionId,
userId,
serviceType,
serviceUserId,
serviceUrl,
accessToken,
refreshToken,
expiry,
)
return connectionId
}
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

@@ -26,12 +26,8 @@ export async function GET({ url, fetch }) {
return authResponse
}
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', 'AccessToken', 'ServerId']
if (!requiredData.every((key) => Object.keys(data).includes(key))) return new Response('Data missing from Jellyfin server response', { status: 500 })
if (!('AccessToken' in data && 'User' in data)) return new Response('Jellyfin server response has missing data', { status: 500 })
const responseData = JSON.stringify(data)
const responseHeaders = new Headers({

View File

@@ -2,70 +2,67 @@ import { UserConnections } from '$lib/server/db/users'
import Joi from 'joi'
/** @type {import('./$types').RequestHandler} */
export async function GET({ request, url }) {
const schema = Joi.number().required()
const userId = request.headers.get('userId')
const validation = schema.validate(userId)
if (validation.error) return new Response(validation.error.message, { status: 400 })
export async function GET({ url }) {
const { userId, filter } = Object.fromEntries(url.searchParams)
if (!userId) return new Response('Requires User Id', { status: 400 })
const responseHeaders = new Headers({
'Content-Type': 'application/json',
})
const filter = url.searchParams.get('filter')
if (filter) {
const requestedConnections = filter.split(',').map((item) => item.toLowerCase())
const userConnections = UserConnections.getConnections(userId, requestedConnections)
const userConnections = UserConnections.getUserConnections(userId, requestedConnections)
return new Response(JSON.stringify(userConnections), { headers: responseHeaders })
}
const userConnections = UserConnections.getConnections(userId)
const userConnections = UserConnections.getUserConnections(userId)
return new Response(JSON.stringify(userConnections), { headers: responseHeaders })
}
// May need to add support for refresh token and expiry in the future
/** @type {import('./$types').RequestHandler} */
export async function PATCH({ request }) {
export async function PATCH({ request, url }) {
const schema = Joi.object({
userId: Joi.number().required(),
userId: Joi.required(),
connection: Joi.object({
serviceType: Joi.string().required(),
serviceUserId: Joi.string().required(),
serviceUrl: Joi.string().required(),
accessToken: Joi.string().required(),
refreshToken: Joi.string(),
expiry: Joi.number(),
connectionInfo: Joi.string(),
}).required(),
})
const userId = request.headers.get('userId')
const userId = url.searchParams.get('userId')
const connection = await request.json()
const validation = schema.validate({ userId, connection })
if (validation.error) return new Response(validation.error.message, { status: 400 })
const { serviceType, accessToken, refreshToken, expiry, connectionInfo } = connection
const newConnectionId = UserConnections.addConnection(userId, serviceType, accessToken, { refreshToken, expiry, connectionInfo })
const { serviceType, serviceUserId, serviceUrl, accessToken, refreshToken, expiry } = connection
const newConnectionId = UserConnections.addConnection(userId, serviceType, serviceUserId, serviceUrl, accessToken, { refreshToken, expiry })
return new Response(JSON.stringify({ id: newConnectionId }))
}
/** @type {import('./$types').RequestHandler} */
export async function DELETE({ request }) {
export async function DELETE({ request, url }) {
const schema = Joi.object({
userId: Joi.number().required(),
userId: Joi.required(),
connection: Joi.object({
serviceId: Joi.string().required(),
connectionId: Joi.string().required(),
}).required(),
})
const userId = request.headers.get('userId')
const userId = url.searchParams.get('userId')
const connection = await request.json()
const validation = schema.validate({ userId, connection })
if (validation.error) return new Response(validation.error.message, { status: 400 })
const deletedConnectionId = UserConnections.deleteConnection(userId, connection.serviceId)
UserConnections.deleteConnection(userId, connection.connectionId)
return new Response(JSON.stringify({ id: deletedConnectionId }))
return new Response('Connection deleted', { status: 200 })
}

View File

@@ -1,29 +1,62 @@
import { fail } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import { UserConnections } from '$lib/server/db/users'
class ConnectionProfile {
static async createProfile(connectionId) {
const connectionData = await this.getUserData(connectionId)
return { connectionId, ...connectionData }
}
static getUserData = async (connectionId) => {
const connectionData = UserConnections.getConnection(connectionId)
const { serviceType, serviceUserId, serviceUrl, accessToken } = connectionData
switch (serviceType) {
case 'jellyfin':
const userUrl = new URL(`Users/${serviceUserId}`, serviceUrl).href
const systemUrl = new URL('System/Info', serviceUrl).href
const reqHeaders = new Headers()
reqHeaders.append('Authorization', `MediaBrowser Token="${accessToken}"`)
const userResponse = await fetch(userUrl, { headers: reqHeaders })
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
const userData = await userResponse.json()
const systemData = await systemResponse.json()
return {
serviceType,
userId: serviceUserId,
username: userData?.Name,
serviceUrl: serviceUrl,
serverName: systemData?.ServerName,
}
default:
return null
}
}
}
/** @type {import('./$types').PageServerLoad} */
export const load = async ({ fetch, locals }) => {
const response = await fetch('/api/user/connections', {
const response = await fetch(`/api/user/connections?userId=${locals.userId}`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId,
},
})
if (response.ok) {
const connectionData = await response.json()
const clientConnectionData = {}
connectionData?.forEach((connection) => {
const { id, serviceType, connectionInfo } = connection
clientConnectionData[id] = {
serviceType,
connectionInfo: JSON.parse(connectionInfo),
const allConnections = await response.json()
const connectionProfiles = []
if (allConnections) {
for (const connection of allConnections) {
const connectionProfile = await ConnectionProfile.createProfile(connection.id)
connectionProfiles.push(connectionProfile)
}
})
return { existingConnections: clientConnectionData }
} else {
const error = await response.text()
console.log(error)
}
return { connectionProfiles }
}
/** @type {import('./$types').Actions}} */
@@ -35,6 +68,7 @@ export const actions = {
const [key, value] = field
queryParams.append(key, value)
}
const jellyfinAuthResponse = await fetch(`/api/jellyfin/auth?${queryParams.toString()}`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
@@ -47,40 +81,38 @@ export const actions = {
}
const jellyfinAuthData = await jellyfinAuthResponse.json()
const { User, AccessToken, ServerId } = jellyfinAuthData
const connectionInfo = JSON.stringify({ User, ServerId })
const updateConnectionsResponse = await fetch('/api/user/connections', {
const accessToken = jellyfinAuthData.AccessToken
const jellyfinUserId = jellyfinAuthData.User.Id
const updateConnectionsResponse = await fetch(`/api/user/connections?userId=${locals.userId}`, {
method: 'PATCH',
headers: {
apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId,
},
body: JSON.stringify({ serviceType: 'jellyfin', accessToken: AccessToken, connectionInfo }),
body: JSON.stringify({ serviceType: 'jellyfin', serviceUserId: jellyfinUserId, serviceUrl: formData.get('serverUrl'), accessToken }),
})
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
const newConnectionData = await updateConnectionsResponse.json()
return { message: 'Added Jellyfin connection', newConnection: { id: newConnectionData.id, serviceType: 'jellyfin', connectionInfo: JSON.parse(connectionInfo) } }
const jellyfinProfile = await ConnectionProfile.createProfile(newConnectionData.id)
return { newConnection: jellyfinProfile }
},
deleteConnection: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const serviceId = formData.get('serviceId')
const connectionId = formData.get('connectionId')
const deleteConnectionResponse = await fetch('/api/user/connections', {
const deleteConnectionResponse = await fetch(`/api/user/connections?userId=${locals.userId}`, {
method: 'DELETE',
headers: {
apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId,
},
body: JSON.stringify({ serviceId }),
body: JSON.stringify({ connectionId }),
})
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
const deletedConnectionData = await deleteConnectionResponse.json()
return { message: 'Connection deleted', deletedConnection: { id: deletedConnectionData.id } }
return { deletedConnectionId: connectionId }
},
}

View File

@@ -9,7 +9,7 @@
import Toggle from '$lib/components/utility/toggle.svelte'
export let data
let existingConnections = data.existingConnections
let connectionProfiles = data.connectionProfiles
const submitCredentials = ({ formData, action, cancel }) => {
switch (action.search) {
@@ -43,18 +43,20 @@
case 'success':
modal = null
if (result.data?.newConnection) {
const { id, serviceType, connectionInfo } = result.data.newConnection
existingConnections[id] = {
serviceType,
connectionInfo,
const newConnection = result.data.newConnection
connectionProfiles = [newConnection, ...connectionProfiles]
return ($newestAlert = ['success', `Added ${Services[newConnection.serviceType].displayName}`])
} else if (result.data?.deletedConnectionId) {
const id = result.data.deletedConnectionId
const indexToDelete = connectionProfiles.findIndex((profile) => profile.connectionId === id)
const serviceType = connectionProfiles[indexToDelete].serviceType
connectionProfiles.splice(indexToDelete, 1)
connectionProfiles = connectionProfiles
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
}
existingConnections = existingConnections
} else if (result.data?.deletedConnection) {
const id = result.data.deletedConnection.id
delete existingConnections[id]
existingConnections = existingConnections
}
return ($newestAlert = ['success', result.data.message])
}
}
}
@@ -80,21 +82,22 @@
</div>
</section>
<div class="grid gap-8">
{#each Object.entries(existingConnections) as [connectionId, connectionData]}
{@const serviceData = Services[connectionData.serviceType]}
{#each connectionProfiles as connectionProfile}
{@const serviceData = Services[connectionProfile.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={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>{connectionProfile?.username ? connectionProfile.username : 'Placeholder Account Name'}</div>
<div class="text-sm text-neutral-500">
{serviceData.displayName}
{#if connectionProfile.serviceType === 'jellyfin'}
- {connectionProfile.serverName}
{/if}
<div class="text-sm text-neutral-500">{serviceData.displayName}</div>
</div>
</div>
<div class="ml-auto h-8">
<IconButton on:click={() => (modal = `delete-${connectionId}`)}>
<IconButton on:click={() => (modal = `delete-${connectionProfile.connectionId}`)}>
<i slot="icon" class="fa-solid fa-link-slash" />
</IconButton>
</div>
@@ -113,11 +116,12 @@
<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 connectionId = modal.replace('delete-', '')}
{@const service = Services[existingConnections[connectionId].serviceType]}
{@const connection = connectionProfiles.find((profile) => profile.connectionId === connectionId)}
{@const serviceData = Services[connection.serviceType]}
<div class="rounded-lg bg-neutral-900 p-5">
<h1 class="pb-4 text-center">Delete {service.displayName} connection?</h1>
<h1 class="pb-4 text-center">Delete {serviceData.displayName} connection?</h1>
<div class="flex w-60 justify-around">
<input type="hidden" name="serviceId" value={connectionId} />
<input type="hidden" name="connectionId" 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>

View File

@@ -0,0 +1,12 @@
{
"connectionId": "Database id of the respective connection",
"serviceType": "Type of service of the respective connection",
"userId": "The UUID of the account [req]",
"username": "The username of the account [req]",
"email": "Email asscociated w/ account [opt]",
"serviceUrl": "Id of source server [req]",
"serverName": "Name of source server [jellyfin]",
"V POTENTIAL": "TBD V",
"connectionEnabled": "[Toggle] boolean; enables/disables pulling data from the respective connection"
}

View File

@@ -1,11 +1,5 @@
/** @type {import('./$types').PageServerLoad} */
export async function load({ url, fetch }) {
const videoId = url.searchParams.get('videoId')
const response = await fetch(`/api/yt/media?videoId=${videoId}`)
const responseData = await response.json()
return {
videoId: videoId,
videoUrl: responseData.video,
audioUrl: responseData.audio,
}
}
export const prerender = false
export const ssr = false
/** @type {import('./$types').PageLoad} */
export async function load() {}

View File

@@ -1,24 +1,16 @@
<script>
import { onMount } from 'svelte'
export let data
let videoElement
let audioElement
onMount(() => {
audioElement.volume = 0.3
})
</script>
<video controls bind:this={videoElement} preload="auto">
<source src={data.videoUrl} type="video/mp4" />
<track kind="captions" />
</video>
<audio controls bind:this={audioElement}
on:play={videoElement.play()}
on:pause={videoElement.pause()}
on:seeked={(videoElement.currentTime = audioElement.currentTime)} preload="auto">
<source src={data.audioUrl} type="audio/webm" />
</audio>
<section class="grid h-full place-items-center">
<div class="aspect-video h-2/3 bg-slate-900">
<video id="video-player" controls></video>
<!-- <iframe
src="https://www.youtube.com/embed/uhx-u5peyeY?controls=0&autoplay=0&showinfo=0&rel=0"
title="Video"
frameBorder="0"
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
class="h-full w-full"
/> -->
</div>
</section>