diff --git a/problems.txt b/problems.txt index e5d4952..8b5d0e8 100644 --- a/problems.txt +++ b/problems.txt @@ -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. diff --git a/src/lib/potentialServices.json b/src/lib/potentialServices.json new file mode 100644 index 0000000..b4910b8 --- /dev/null +++ b/src/lib/potentialServices.json @@ -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" + } +} diff --git a/src/lib/server/db/users.db b/src/lib/server/db/users.db index eb84964..b20b4d6 100644 Binary files a/src/lib/server/db/users.db and b/src/lib/server/db/users.db differ diff --git a/src/lib/server/db/users.js b/src/lib/server/db/users.js index 89b6936..b829f5f 100644 --- a/src/lib/server/db/users.js +++ b/src/lib/server/db/users.js @@ -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 } } diff --git a/src/lib/services.json b/src/lib/services.json index fc73c1c..292db17 100644 --- a/src/lib/services.json +++ b/src/lib/services.json @@ -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" } } diff --git a/src/routes/api/jellyfin/auth/+server.js b/src/routes/api/jellyfin/auth/+server.js index 99297e9..5c08955 100644 --- a/src/routes/api/jellyfin/auth/+server.js +++ b/src/routes/api/jellyfin/auth/+server.js @@ -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 }) diff --git a/src/routes/api/user/connections/+server.js b/src/routes/api/user/connections/+server.js index 3ad059c..578ec6f 100644 --- a/src/routes/api/user/connections/+server.js +++ b/src/routes/api/user/connections/+server.js @@ -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 })) } diff --git a/src/routes/settings/connections/+page.server.js b/src/routes/settings/connections/+page.server.js index 8601db1..8f1b567 100644 --- a/src/routes/settings/connections/+page.server.js +++ b/src/routes/settings/connections/+page.server.js @@ -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 } } }, } diff --git a/src/routes/settings/connections/+page.svelte b/src/routes/settings/connections/+page.svelte index d426b25..7e1ddea 100644 --- a/src/routes/settings/connections/+page.svelte +++ b/src/routes/settings/connections/+page.svelte @@ -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,57 +62,63 @@ let modal -
+

Add Connection

- {#each Object.entries(testServices) as [serviceType, serviceData]} - {#if !existingConnections.includes(serviceType)} - - {/if} + {#each Object.entries(Services) as [serviceType, serviceData]} + {/each}
- {#if existingConnections} -
- {#each existingConnections as connectionType} - {@const service = Services[connectionType]} -
-
- {service.displayName} icon -
+
+ {#each Object.entries(existingConnections) as [connectionId, connectionData]} + {@const serviceData = Services[connectionData.serviceType]} +
+
+ {serviceData.displayName} icon +
+ {#if connectionData.serviceType === 'jellyfin'} +
{connectionData.connectionInfo.User.Name}
+ {:else}
Account Name
-
{service.displayName}
-
-
- (modal = `delete-${connectionType}`)}> - - -
-
-
-
-
- console.log(event.detail.toggleState)} /> - Enable Connection -
+ {/if} +
{serviceData.displayName}
-
- {/each} -
- {/if} +
+ (modal = `delete-${connectionId}`)}> + + +
+
+
+
+
+ console.log(event.detail.toggleState)} /> + Enable Connection +
+
+
+ {/each} +
{#if modal} -
+ {#if typeof modal === 'string'} - {@const connectionType = modal.replace('delete-', '')} - {@const service = Services[connectionType]} + {@const connectionId = modal.replace('delete-', '')} + {@const service = Services[existingConnections[connectionId].serviceType]}
-

Delete {service.displayName}?

+

Delete {service.displayName} connection?

- - + +
diff --git a/src/routes/settings/connections/jellyfinAuthBox.svelte b/src/routes/settings/connections/jellyfinAuthBox.svelte index 7350e77..d23b5cc 100644 --- a/src/routes/settings/connections/jellyfinAuthBox.svelte +++ b/src/routes/settings/connections/jellyfinAuthBox.svelte @@ -2,23 +2,20 @@ import { createEventDispatcher } from 'svelte' const dispatch = createEventDispatcher() - const dispatchClose = () => { - dispatch('close') - }

Jellyfin Sign In

- +
- - + +
- - + +