Lazuli, now with openapi!

This commit is contained in:
Eclypsed
2024-02-18 00:01:54 -05:00
parent 85a17dcd89
commit 1608e03b97
14 changed files with 177 additions and 291 deletions

40
src/app.d.ts vendored
View File

@@ -32,17 +32,13 @@ declare global {
urlOrigin: string urlOrigin: string
} }
interface Tokens {
accessToken: string
refreshToken?: string
expiry?: number
}
interface Connection { interface Connection {
id: string id: string
userId: string userId: string
service: Service service: Service
tokens: Tokens accessToken: string
refreshToken?: string
expiry?: number
} }
interface ConnectionInfo { 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 // 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 JFService extends Service {
type: 'jellyfin'
}
interface JFTokens implements Tokens {
accessToken: string
}
interface JFConnection extends Connection {
service: JFService
tokens: JFTokens
}
interface User { interface User {
Name: string Name: string
Id: string Id: string
@@ -184,22 +167,7 @@ declare global {
} }
} }
namespace YouTubeMusic { 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
}
}
} }
export {} export {}

View File

@@ -1,5 +1,7 @@
import { redirect, type Handle } from '@sveltejs/kit' import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit'
import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY } from '$env/static/private' 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' import jwt from 'jsonwebtoken'
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
@@ -25,3 +27,31 @@ export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event) const response = await resolve(event)
return response 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)
}

Binary file not shown.

View File

@@ -5,7 +5,15 @@ 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(id VARCHAR(36) PRIMARY KEY, username VARCHAR(30) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)' 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) db.exec(initUsersTable), db.exec(initConnectionsTable)
type UserQueryParams = { type UserQueryParams = {
@@ -16,7 +24,9 @@ interface ConnectionsTableSchema {
id: string id: string
userId: string userId: string
service: string service: string
tokens: string accessToken: string
refreshToken?: string
expiry?: number
} }
export class Users { export class Users {
@@ -52,8 +62,8 @@ export class Users {
export class Connections { export class Connections {
static getConnection = (id: string): Connection => { static getConnection = (id: string): Connection => {
const { userId, service, tokens } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as ConnectionsTableSchema 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), tokens: JSON.parse(tokens) } const connection: Connection = { id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry }
return connection return connection
} }
@@ -61,16 +71,16 @@ export class Connections {
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: Connection[] = []
for (const row of connectionRows) { for (const row of connectionRows) {
const { id, service, tokens } = row const { id, service, accessToken, refreshToken, expiry } = row
connections.push({ id, userId, service: JSON.parse(service), tokens: JSON.parse(tokens) }) connections.push({ id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry })
} }
return connections 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() const connectionId = generateUUID()
if (!isValidURL(service.urlOrigin)) throw new Error('Service does not have valid url') 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) 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`) if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
} }
static updateTokens = (id: string, tokens: Tokens): void => { static updateTokens = (id: string, accessToken: string, refreshToken?: string, expiry?: number): void => {
const commandInfo = db.prepare('UPDATE Connections SET tokens = ? WHERE id = ?').run(JSON.stringify(tokens), id) 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') 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
}
} }

View File

@@ -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 { id, 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
@@ -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 { id, 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
@@ -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 { id, 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
@@ -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 { id, 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
@@ -100,24 +100,4 @@ export class Jellyfin {
thumbnail, thumbnail,
} }
} }
static connectionInfo = async (connection: Jellyfin.JFConnection): Promise<ConnectionInfo> => {
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,
}
}
} }

View File

@@ -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' import { google } from 'googleapis'
export class YouTubeMusic { export class YouTubeMusic {}
static refreshAccessToken = async (connectionId: string, refreshToken: string): Promise<Tokens> => {
// 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<ConnectionInfo> => {
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,
}
}
}

View File

@@ -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 })
}

View File

@@ -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<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,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 })
}
}

View File

@@ -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 })
}

View File

@@ -1,48 +1,9 @@
import { Connections } from '$lib/server/users' import { Connections } from '$lib/server/users'
import { isValidURL } from '$lib/utils'
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { z } from 'zod'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId as string const userId = params.userId!
const connections = Connections.getUserConnections(userId) const connections = Connections.getUserConnections(userId)
return Response.json(connections) 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 })
}
} }

View File

@@ -5,15 +5,15 @@ import { Jellyfin } from '$lib/service-managers/jellyfin'
// 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
export const GET: RequestHandler = async ({ params, fetch }) => { 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 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) { for (const connection of userConnections.connections) {
const { service, tokens } = connection const { service, accessToken } = connection as Connection
switch (service.type) { switch (service.type) {
case 'jellyfin': 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 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 mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: requestHeaders })
const mostPlayedData = await mostPlayedResponse.json() 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 break
} }
} }

View File

@@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import type { PageServerLoad, Actions } from './$types' import type { PageServerLoad, Actions } from './$types'
import { Connections } from '$lib/server/users'
import { google } from 'googleapis' import { google } from 'googleapis'
export const load: PageServerLoad = async ({ fetch, locals }) => { export const load: PageServerLoad = async ({ fetch, locals }) => {
@@ -10,9 +11,8 @@ export const load: PageServerLoad = async ({ fetch, locals }) => {
headers: { apikey: SECRET_INTERNAL_API_KEY }, headers: { apikey: SECRET_INTERNAL_API_KEY },
}) })
console.log(locals.user.id) const userConnections = await connectionsResponse.json()
const userConnections: Connection[] = await connectionsResponse.json() return { connections: userConnections.connections }
return { userConnections }
} }
export const actions: Actions = { export const actions: Actions = {
@@ -20,92 +20,63 @@ export const actions: Actions = {
const formData = await request.formData() const formData = await request.formData()
const { serverUrl, username, password, deviceId } = Object.fromEntries(formData) const { serverUrl, username, password, deviceId } = Object.fromEntries(formData)
const jellyfinAuthResponse = await fetch('/api/jellyfin/auth', { const authUrl = new URL('/Users/AuthenticateByName', serverUrl.toString()).href
method: 'POST', let authData: Jellyfin.AuthData
headers: { apikey: SECRET_INTERNAL_API_KEY }, try {
body: JSON.stringify({ serverUrl, username, password, deviceId }), 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 (!authResponse.ok) return fail(401, { message: 'Failed to authenticate' })
if (jellyfinAuthResponse.status === 404) {
return fail(404, { message: 'Request failed, check Server URL' })
} else if (jellyfinAuthResponse.status === 401) {
return fail(401, { message: 'Invalid Credentials' })
}
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: Service = {
const serviceData: Jellyfin.JFService = {
type: 'jellyfin', type: 'jellyfin',
userId: authData.User.Id, userId: authData.User.Id,
urlOrigin: serverUrl.toString(), urlOrigin: serverUrl.toString(),
} }
const tokenData: Jellyfin.JFTokens = {
accessToken: authData.AccessToken,
}
const newConnectionResponse = await fetch(`/api/users/${locals.user.id}/connections`, { const newConnection = Connections.addConnection(locals.user.id, serviceData, authData.AccessToken)
method: 'POST',
headers: { apikey: SECRET_INTERNAL_API_KEY },
body: JSON.stringify({ service: serviceData, tokens: tokenData }),
})
if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
const newConnection: Jellyfin.JFConnection = await newConnectionResponse.json()
return { newConnection } return { newConnection }
}, },
youtubeMusicLogin: async ({ request, fetch, locals }) => { youtubeMusicLogin: async ({ request, locals }) => {
const formData = await request.formData() const formData = await request.formData()
const { code } = Object.fromEntries(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 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 { 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 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 userChannel = userChannelResponse.data.items![0]
const serviceData: YouTubeMusic.YTService = { const serviceData: Service = {
type: 'youtube-music', type: 'youtube-music',
userId: userChannel.id as string, userId: userChannel.id!,
urlOrigin: 'https://www.googleapis.com/youtube/v3', urlOrigin: 'https://www.googleapis.com/youtube/v3',
} }
const newConnectionResponse = await fetch(`/api/users/${locals.user.id}/connections`, { const newConnection = Connections.addConnection(locals.user.id, serviceData, tokens.access_token!, tokens.refresh_token!, tokens.expiry_date!)
method: 'POST',
headers: { apikey: SECRET_INTERNAL_API_KEY },
body: JSON.stringify({ service: serviceData, tokens: tokenData }),
})
if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
const newConnection: YouTubeMusic.YTConnection = await newConnectionResponse.json()
return { newConnection } return { newConnection }
}, },
refreshConnection: async ({ request }) => { deleteConnection: async ({ request }) => {
const formData = await request.formData() const formData = await request.formData()
const connectionId = formData.get('connectionId')?.toString() as string const connectionId = formData.get('connectionId')!.toString()
},
deleteConnection: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const connectionId = formData.get('connectionId')?.toString() as string
const deleteConnectionResponse = await fetch(`/api/users/${locals.user.id}/connections`, { Connections.deleteConnection(connectionId)
method: 'DELETE',
headers: { apikey: SECRET_INTERNAL_API_KEY },
body: JSON.stringify({ connectionId }),
})
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { deletedConnectionId: connectionId } return { deletedConnectionId: connectionId }
}, },

View File

@@ -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 = data.userConnections let connections: Connection[] = 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)