diff --git a/src/app.d.ts b/src/app.d.ts index 717fedc..e58412e 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -32,17 +32,13 @@ declare global { urlOrigin: string } - interface Tokens { - accessToken: string - refreshToken?: string - expiry?: number - } - interface Connection { id: string userId: string service: Service - tokens: Tokens + accessToken: string + refreshToken?: string + expiry?: number } interface ConnectionInfo { @@ -106,19 +102,6 @@ declare global { // The jellyfin API will not always return the data it says it will, for example /Users/AuthenticateByName says it will // retrun the ServerName, it wont. This must be fetched from /System/Info. // So, ONLY DEFINE THE INTERFACES FOR DATA THAT IS GARUNTEED TO BE RETURNED (unless the data value itself is inherently optional) - interface JFService extends Service { - type: 'jellyfin' - } - - interface JFTokens implements Tokens { - accessToken: string - } - - interface JFConnection extends Connection { - service: JFService - tokens: JFTokens - } - interface User { Name: string Id: string @@ -184,22 +167,7 @@ declare global { } } - namespace YouTubeMusic { - interface YTService extends Service { - type: 'youtube-music' - } - - interface YTTokens implements Tokens { - accessToken: string - refreshToken: string - expiry: number - } - - interface YTConnection extends Connection { - service: YTService - tokens: YTTokens - } - } + namespace YouTubeMusic {} } export {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index f38d5a4..86ec659 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,7 @@ -import { redirect, type Handle } from '@sveltejs/kit' -import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY } from '$env/static/private' +import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit' +import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' +import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' +import { Connections } from '$lib/server/users' import jwt from 'jsonwebtoken' export const handle: Handle = async ({ event, resolve }) => { @@ -25,3 +27,31 @@ export const handle: Handle = async ({ event, resolve }) => { const response = await resolve(event) return response } + +// Access token refresh middleware - checks for expired connections and refreshes them accordingly +export const handleFetch: HandleFetch = async ({ request, fetch, event }) => { + if (event.locals.user) { + const expiredConnection = Connections.getExpiredConnections(event.locals.user.id) + for (const connection of expiredConnection) { + switch (connection.service.type) { + case 'youtube-music': + // Again DON'T SHIP THIS, CLIENT SECRET SHOULD NOT BE EXPOSED TO USERS + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + body: JSON.stringify({ + client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, + client_secret: YOUTUBE_API_CLIENT_SECRET, + refresh_token: connection.refreshToken as string, + grant_type: 'refresh_token', + }), + }) + const { access_token, expires_in } = await response.json() + const newExpiry = Date.now() + expires_in * 1000 + Connections.updateTokens(connection.id, access_token, connection.refreshToken, newExpiry) + console.log('Refreshed YouTubeMusic access token') + } + } + } + + return fetch(request) +} diff --git a/src/lib/server/users.db b/src/lib/server/users.db index 8552a2c..7696ed7 100644 Binary files a/src/lib/server/users.db and b/src/lib/server/users.db differ diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts index b3248ec..58abf85 100644 --- a/src/lib/server/users.ts +++ b/src/lib/server/users.ts @@ -5,7 +5,15 @@ import { isValidURL } from '$lib/utils' const db = new Database('./src/lib/server/users.db', { verbose: console.info }) db.pragma('foreign_keys = ON') const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id VARCHAR(36) PRIMARY KEY, username VARCHAR(30) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)' -const initConnectionsTable = 'CREATE TABLE IF NOT EXISTS Connections(id VARCHAR(36) PRIMARY KEY, userId VARCHAR(36) NOT NULL, service TEXT NOT NULL, tokens TEXT NOT NULL, FOREIGN KEY(userId) REFERENCES Users(id))' +const initConnectionsTable = `CREATE TABLE IF NOT EXISTS Connections( + id VARCHAR(36) PRIMARY KEY, + userId VARCHAR(36) NOT NULL, + service TEXT NOT NULL, + accessToken TEXT NOT NULL, + refreshToken TEXT, + expiry NUMBER, + FOREIGN KEY(userId) REFERENCES Users(id) +)` db.exec(initUsersTable), db.exec(initConnectionsTable) type UserQueryParams = { @@ -16,7 +24,9 @@ interface ConnectionsTableSchema { id: string userId: string service: string - tokens: string + accessToken: string + refreshToken?: string + expiry?: number } export class Users { @@ -52,8 +62,8 @@ export class Users { export class Connections { static getConnection = (id: string): Connection => { - const { userId, service, tokens } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as ConnectionsTableSchema - const connection: Connection = { id, userId, service: JSON.parse(service), tokens: JSON.parse(tokens) } + const { userId, service, accessToken, refreshToken, expiry } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as ConnectionsTableSchema + const connection: Connection = { id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry } return connection } @@ -61,16 +71,16 @@ export class Connections { const connectionRows = db.prepare('SELECT * FROM Connections WHERE userId = ?').all(userId) as ConnectionsTableSchema[] const connections: Connection[] = [] for (const row of connectionRows) { - const { id, service, tokens } = row - connections.push({ id, userId, service: JSON.parse(service), tokens: JSON.parse(tokens) }) + const { id, service, accessToken, refreshToken, expiry } = row + connections.push({ id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry }) } return connections } - static addConnection = (userId: string, service: Service, tokens: Tokens): Connection => { + static addConnection = (userId: string, service: Service, accessToken: string, refreshToken?: string, expiry?: number): Connection => { const connectionId = generateUUID() if (!isValidURL(service.urlOrigin)) throw new Error('Service does not have valid url') - db.prepare('INSERT INTO Connections(id, userId, service, tokens) VALUES(?, ?, ?, ?)').run(connectionId, userId, JSON.stringify(service), JSON.stringify(tokens)) + 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) } @@ -79,8 +89,18 @@ export class Connections { if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`) } - static updateTokens = (id: string, tokens: Tokens): void => { - const commandInfo = db.prepare('UPDATE Connections SET tokens = ? WHERE id = ?').run(JSON.stringify(tokens), id) + 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) if (commandInfo.changes === 0) throw new Error('Failed to update tokens') } + + static getExpiredConnections = (userId: string): Connection[] => { + const expiredRows = db.prepare('SELECT * FROM Connections WHERE userId = ? AND expiry < ?').all(userId, Date.now()) as ConnectionsTableSchema[] + const connections: Connection[] = [] + for (const row of expiredRows) { + const { id, userId, service, accessToken, refreshToken, expiry } = row + connections.push({ id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry }) + } + return connections + } } diff --git a/src/lib/service-managers/jellyfin.ts b/src/lib/service-managers/jellyfin.ts index 9b23bf3..2e255cb 100644 --- a/src/lib/service-managers/jellyfin.ts +++ b/src/lib/service-managers/jellyfin.ts @@ -10,7 +10,7 @@ export class Jellyfin { } } - static songFactory = (song: Jellyfin.Song, connection: Jellyfin.JFConnection): Song => { + static songFactory = (song: Jellyfin.Song, connection: Connection): Song => { const { id, service } = connection const thumbnail = song.ImageTags?.Primary ? new URL(`Items/${song.Id}/Images/Primary`, service.urlOrigin).href @@ -42,7 +42,7 @@ export class Jellyfin { } } - static albumFactory = (album: Jellyfin.Album, connection: Jellyfin.JFConnection): Album => { + static albumFactory = (album: Jellyfin.Album, connection: Connection): Album => { const { id, service } = connection const thumbnail = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, service.urlOrigin).href : undefined @@ -72,7 +72,7 @@ export class Jellyfin { } } - static playListFactory = (playlist: Jellyfin.Playlist, connection: Jellyfin.JFConnection): Playlist => { + static playListFactory = (playlist: Jellyfin.Playlist, connection: Connection): Playlist => { const { id, service } = connection const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, service.urlOrigin).href : undefined @@ -87,7 +87,7 @@ export class Jellyfin { } } - static artistFactory = (artist: Jellyfin.Artist, connection: Jellyfin.JFConnection): Artist => { + static artistFactory = (artist: Jellyfin.Artist, connection: Connection): Artist => { const { id, service } = connection const thumbnail = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, service.urlOrigin).href : undefined @@ -100,24 +100,4 @@ export class Jellyfin { thumbnail, } } - - static connectionInfo = async (connection: Jellyfin.JFConnection): Promise => { - const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${connection.tokens.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, - } - } } diff --git a/src/lib/service-managers/youtubeMusic.ts b/src/lib/service-managers/youtubeMusic.ts index 1ea399f..6e56e59 100644 --- a/src/lib/service-managers/youtubeMusic.ts +++ b/src/lib/service-managers/youtubeMusic.ts @@ -1,46 +1,3 @@ -import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' -import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' -import { Connections } from '$lib/server/users' import { google } from 'googleapis' -export class YouTubeMusic { - static refreshAccessToken = async (connectionId: string, refreshToken: string): Promise => { - // Again DON'T SHIP THIS, CLIENT SECRET SHOULD NOT BE EXPOSED TO USERS - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - body: JSON.stringify({ - client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, - client_secret: YOUTUBE_API_CLIENT_SECRET, - refresh_token: refreshToken, - grant_type: 'refresh_token', - }), - }) - const { access_token, expires_in } = await response.json() - const newTokens: Tokens = { - accessToken: access_token, - refreshToken, - expiry: Date.now() + expires_in * 1000, - } - Connections.updateTokens(connectionId, newTokens) - return newTokens - } - - static connectionInfo = async (connection: YouTubeMusic.YTConnection): Promise => { - let accessToken = connection.tokens.accessToken - if (Date.now() > connection.tokens.expiry) { - const newTokenData = await this.refreshAccessToken(connection.id, connection.tokens.refreshToken) - accessToken = newTokenData.accessToken - } - - 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 { - connectionId: connection.id, - serviceType: connection.service.type, - username: userChannel.snippet?.title as string, - profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined, - } - } -} +export class YouTubeMusic {} diff --git a/src/routes/api/connections/+server.ts b/src/routes/api/connections/+server.ts new file mode 100644 index 0000000..d83c011 --- /dev/null +++ b/src/routes/api/connections/+server.ts @@ -0,0 +1,12 @@ +import type { RequestHandler } from '@sveltejs/kit' +import { Connections } from '$lib/server/users' + +export const GET: RequestHandler = async ({ url }) => { + const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',') + if (!ids) return new Response('Missing ids query parameter', { status: 400 }) + + const connections: Connection[] = [] + for (const connectionId of ids) connections.push(Connections.getConnection(connectionId)) + + return Response.json({ connections }) +} diff --git a/src/routes/api/connections/[connectionId]/info/+server.ts b/src/routes/api/connections/[connectionId]/info/+server.ts new file mode 100644 index 0000000..6901b64 --- /dev/null +++ b/src/routes/api/connections/[connectionId]/info/+server.ts @@ -0,0 +1,53 @@ +import type { RequestHandler } from '@sveltejs/kit' +import { Connections } from '$lib/server/users' +import { google } from 'googleapis' + +const youtubeInfo = async (connection: Connection): Promise => { + 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 => { + 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 }) +} diff --git a/src/routes/api/jellyfin/auth/+server.ts b/src/routes/api/jellyfin/auth/+server.ts deleted file mode 100644 index c8b926f..0000000 --- a/src/routes/api/jellyfin/auth/+server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit' -import { isValidURL } from '$lib/utils' -import { z } from 'zod' - -export const POST: RequestHandler = async ({ request, fetch }) => { - const jellyfinAuthSchema = z.object({ - serverUrl: z.string().refine((val) => isValidURL(val)), - username: z.string(), - password: z.string(), - deviceId: z.string(), - }) - - const jellyfinAuthData = await request.json() - const jellyfinAuthValidation = jellyfinAuthSchema.safeParse(jellyfinAuthData) - if (!jellyfinAuthValidation.success) return new Response('Invalid data in request body', { status: 400 }) - - const { serverUrl, username, password, deviceId } = jellyfinAuthValidation.data - const authUrl = new URL('/Users/AuthenticateByName', serverUrl).href - try { - const authResponse = await fetch(authUrl, { - method: 'POST', - body: JSON.stringify({ - Username: username, - Pw: password, - }), - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="1.0.0.0"`, - }, - }) - if (!authResponse.ok) return new Response('Failed to authenticate', { status: 401 }) - - const authData: Jellyfin.AuthData = await authResponse.json() - return Response.json(authData) - } catch { - return new Response('Fetch request failed', { status: 404 }) - } -} diff --git a/src/routes/api/users/[userId]/connectionInfo/+server.ts b/src/routes/api/users/[userId]/connectionInfo/+server.ts deleted file mode 100644 index 749545d..0000000 --- a/src/routes/api/users/[userId]/connectionInfo/+server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit' -import { YouTubeMusic } from '$lib/service-managers/youtubeMusic' -import { Jellyfin } from '$lib/service-managers/jellyfin' -import { Connections } from '$lib/server/users' - -export const GET: RequestHandler = async ({ params, url }) => { - const userId = params.userId as string - const requestedConnectionIds = url.searchParams.get('connectionIds')?.split(',') - - const connectionInfo: ConnectionInfo[] = [] - - const userConnections: Connection[] = requestedConnectionIds ? Array.from(requestedConnectionIds, (id) => Connections.getConnection(id)) : Connections.getUserConnections(userId) - - for (const connection of userConnections) { - let info: ConnectionInfo - switch (connection.service.type) { - case 'jellyfin': - info = await Jellyfin.connectionInfo(connection as Jellyfin.JFConnection) - break - case 'youtube-music': - info = await YouTubeMusic.connectionInfo(connection as YouTubeMusic.YTConnection) - break - } - connectionInfo.push(info) - } - - return Response.json({ connectionInfo }) -} diff --git a/src/routes/api/users/[userId]/connections/+server.ts b/src/routes/api/users/[userId]/connections/+server.ts index 1da6eb7..9ac189a 100644 --- a/src/routes/api/users/[userId]/connections/+server.ts +++ b/src/routes/api/users/[userId]/connections/+server.ts @@ -1,48 +1,9 @@ import { Connections } from '$lib/server/users' -import { isValidURL } from '$lib/utils' import type { RequestHandler } from '@sveltejs/kit' -import { z } from 'zod' export const GET: RequestHandler = async ({ params }) => { - const userId = params.userId as string + const userId = params.userId! const connections = Connections.getUserConnections(userId) - return Response.json(connections) -} - -// This schema should be identical to the Connection Data Type but without the id and userId -const newConnectionSchema = z.object({ - service: z.object({ - type: z.enum(['jellyfin', 'youtube-music']), - userId: z.string(), - urlOrigin: z.string().refine((val) => isValidURL(val)), - }), - tokens: z.object({ - accessToken: z.string(), - refreshToken: z.string().optional(), - expiry: z.number().optional(), - }), -}) - -export const POST: RequestHandler = async ({ params, request }) => { - const userId = params.userId as string - - const connection: Connection = await request.json() - - const connectionValidation = newConnectionSchema.safeParse(connection) - if (!connectionValidation.success) return new Response(connectionValidation.error.message, { status: 400 }) - - const { service, tokens } = connection - const newConnection = Connections.addConnection(userId, service, tokens) - return Response.json(newConnection) -} - -export const DELETE: RequestHandler = async ({ request }) => { - const requestData = await request.json() - try { - Connections.deleteConnection(requestData.connectionId) - return new Response('Connection Deleted') - } catch (error) { - return new Response('Connection does not exist', { status: 400 }) - } + return Response.json({ connections }) } diff --git a/src/routes/api/users/[userId]/recommendations/+server.ts b/src/routes/api/users/[userId]/recommendations/+server.ts index 323ddd4..583804a 100644 --- a/src/routes/api/users/[userId]/recommendations/+server.ts +++ b/src/routes/api/users/[userId]/recommendations/+server.ts @@ -5,15 +5,15 @@ import { Jellyfin } from '$lib/service-managers/jellyfin' // This is temporary functionally for the sake of developing the app. // In the future will implement more robust algorithm for offering recommendations export const GET: RequestHandler = async ({ params, fetch }) => { - const userId = params.userId as string + const userId = params.userId! const connectionsResponse = await fetch(`/api/users/${userId}/connections`, { headers: { apikey: SECRET_INTERNAL_API_KEY } }) - const userConnections: Connection[] = await connectionsResponse.json() + const userConnections = await connectionsResponse.json() - const recommendations: Song[] = [] + const recommendations: MediaItem[] = [] - for (const connection of userConnections) { - const { service, tokens } = connection + for (const connection of userConnections.connections) { + const { service, accessToken } = connection as Connection switch (service.type) { case 'jellyfin': @@ -26,12 +26,12 @@ export const GET: RequestHandler = async ({ params, fetch }) => { }) const mostPlayedSongsURL = new URL(`/Users/${service.userId}/Items?${mostPlayedSongsSearchParams.toString()}`, service.urlOrigin).href - const requestHeaders = new Headers({ Authorization: `MediaBrowser Token="${tokens.accessToken}"` }) + const requestHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` }) const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: requestHeaders }) const mostPlayedData = await mostPlayedResponse.json() - mostPlayedData.Items.forEach((song: Jellyfin.Song) => recommendations.push(Jellyfin.songFactory(song, connection as Jellyfin.JFConnection))) + for (const song of mostPlayedData.Items) recommendations.push(Jellyfin.songFactory(song, connection)) break } } diff --git a/src/routes/settings/connections/+page.server.ts b/src/routes/settings/connections/+page.server.ts index bac692a..906520e 100644 --- a/src/routes/settings/connections/+page.server.ts +++ b/src/routes/settings/connections/+page.server.ts @@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit' import { SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import type { PageServerLoad, Actions } from './$types' +import { Connections } from '$lib/server/users' import { google } from 'googleapis' export const load: PageServerLoad = async ({ fetch, locals }) => { @@ -10,9 +11,8 @@ export const load: PageServerLoad = async ({ fetch, locals }) => { headers: { apikey: SECRET_INTERNAL_API_KEY }, }) - console.log(locals.user.id) - const userConnections: Connection[] = await connectionsResponse.json() - return { userConnections } + const userConnections = await connectionsResponse.json() + return { connections: userConnections.connections } } export const actions: Actions = { @@ -20,92 +20,63 @@ export const actions: Actions = { const formData = await request.formData() const { serverUrl, username, password, deviceId } = Object.fromEntries(formData) - const jellyfinAuthResponse = await fetch('/api/jellyfin/auth', { - method: 'POST', - headers: { apikey: SECRET_INTERNAL_API_KEY }, - body: JSON.stringify({ serverUrl, username, password, deviceId }), - }) + const authUrl = new URL('/Users/AuthenticateByName', serverUrl.toString()).href + let authData: Jellyfin.AuthData + try { + const authResponse = await fetch(authUrl, { + method: 'POST', + body: JSON.stringify({ + Username: username, + Pw: password, + }), + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="1.0.0.0"`, + }, + }) - if (!jellyfinAuthResponse.ok) { - if (jellyfinAuthResponse.status === 404) { - return fail(404, { message: 'Request failed, check Server URL' }) - } else if (jellyfinAuthResponse.status === 401) { - return fail(401, { message: 'Invalid Credentials' }) - } + if (!authResponse.ok) return fail(401, { message: 'Failed to authenticate' }) - return fail(500, { message: 'Internal Server Error' }) + authData = await authResponse.json() + } catch { + return fail(400, { message: 'Could not reach Jellyfin server' }) } - const authData: Jellyfin.AuthData = await jellyfinAuthResponse.json() - - const serviceData: Jellyfin.JFService = { + const serviceData: Service = { type: 'jellyfin', userId: authData.User.Id, urlOrigin: serverUrl.toString(), } - const tokenData: Jellyfin.JFTokens = { - accessToken: authData.AccessToken, - } - const newConnectionResponse = await fetch(`/api/users/${locals.user.id}/connections`, { - method: 'POST', - headers: { apikey: SECRET_INTERNAL_API_KEY }, - body: JSON.stringify({ service: serviceData, tokens: tokenData }), - }) + const newConnection = Connections.addConnection(locals.user.id, serviceData, authData.AccessToken) - if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' }) - - const newConnection: Jellyfin.JFConnection = await newConnectionResponse.json() return { newConnection } }, - youtubeMusicLogin: async ({ request, fetch, locals }) => { + youtubeMusicLogin: async ({ request, locals }) => { const formData = await request.formData() const { code } = Object.fromEntries(formData) const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD. const { tokens } = await client.getToken(code.toString()) - const tokenData: YouTubeMusic.YTTokens = { - accessToken: tokens.access_token as string, - refreshToken: tokens.refresh_token as string, - expiry: tokens.expiry_date as number, - } - const youtube = google.youtube('v3') - const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokenData.accessToken }) + const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! }) const userChannel = userChannelResponse.data.items![0] - const serviceData: YouTubeMusic.YTService = { + const serviceData: Service = { type: 'youtube-music', - userId: userChannel.id as string, + userId: userChannel.id!, urlOrigin: 'https://www.googleapis.com/youtube/v3', } - const newConnectionResponse = await fetch(`/api/users/${locals.user.id}/connections`, { - method: 'POST', - headers: { apikey: SECRET_INTERNAL_API_KEY }, - body: JSON.stringify({ service: serviceData, tokens: tokenData }), - }) + const newConnection = Connections.addConnection(locals.user.id, serviceData, tokens.access_token!, tokens.refresh_token!, tokens.expiry_date!) - if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' }) - - const newConnection: YouTubeMusic.YTConnection = await newConnectionResponse.json() return { newConnection } }, - refreshConnection: async ({ request }) => { + deleteConnection: async ({ request }) => { const formData = await request.formData() - const connectionId = formData.get('connectionId')?.toString() as string - }, - deleteConnection: async ({ request, fetch, locals }) => { - const formData = await request.formData() - const connectionId = formData.get('connectionId')?.toString() as string + const connectionId = formData.get('connectionId')!.toString() - const deleteConnectionResponse = await fetch(`/api/users/${locals.user.id}/connections`, { - method: 'DELETE', - headers: { apikey: SECRET_INTERNAL_API_KEY }, - body: JSON.stringify({ connectionId }), - }) - - if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' }) + Connections.deleteConnection(connectionId) return { deletedConnectionId: connectionId } }, diff --git a/src/routes/settings/connections/+page.svelte b/src/routes/settings/connections/+page.svelte index 0925c3a..0f4ce51 100644 --- a/src/routes/settings/connections/+page.svelte +++ b/src/routes/settings/connections/+page.svelte @@ -11,7 +11,7 @@ import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' export let data: PageServerData - let connections = data.userConnections + let connections: Connection[] = data.connections const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => { const { serverUrl, username, password } = Object.fromEntries(formData)