Resolved merge conflicts

This commit is contained in:
Eclypsed
2024-02-19 11:12:14 -05:00
14 changed files with 394 additions and 232 deletions

202
openapi.yaml Normal file
View File

@@ -0,0 +1,202 @@
openapi: 3.0.0
info:
title: Lazuli API
version: 1.0.0
servers:
- url: http://host[:port]/api
paths:
/connections:
get:
summary: Returns connections by ids
tags:
- Connections
parameters:
- in: query
name: ids
schema:
type: array
items:
type: string
description: List of connection ids, comma delimited
required: true
responses:
'200':
description: A JSON array of connections
content:
application/json:
schema:
type: object
required:
- connections
properties:
connections:
type: array
items:
$ref: '#/components/schemas/Connection'
'400':
description: Bad Request
'401':
description: Unauthorized
/connections/{connectionId}/info:
get:
summary: Returns unique info from the specified connection
tags:
- Connections
parameters:
- in: path
name: connectionId
schema:
type: string
description: The id of the connection
required: true
responses:
'200':
description: Any info relevant to the connection, such as username, profile picture, etc.
content:
application/json:
schema:
type: object
required:
- info
properties:
info:
$ref: '#/components/schemas/ConnectionInfo'
'401':
description: Unauthorized
/users/{userId}/connections:
get:
summary: Returns all connections for a specified user
tags:
- Connections
parameters:
- in: path
name: userId
schema:
type: string
description: The user's id
required: true
responses:
'200':
description: A JSON array of connections
content:
application/json:
schema:
type: object
required:
- connections
properties:
connections:
type: array
items:
$ref: '#/components/schemas/Connection'
'401':
description: Unauthorized
/users/{userId}/recommendations:
get:
summary: Returns recommendations for the user from all connections
tags:
- Recommendations
parameters:
- in: path
name: userId
schema:
type: string
description: The user's id
required: true
responses:
'200':
description: A JSON array of media items
content:
application/json:
schema:
type: object
required:
- recommendations
properties:
recommendations:
type: array
items:
$ref: '#/components/schemas/MediaItem'
'401':
description: Unauthorized
components:
schemas:
serviceType:
type: string
enum: ['jellyfin', 'youtube-music']
Service:
type: object
required:
- type
- userId
- urlOrigin
properties:
type:
$ref: '#/components/schemas/serviceType'
userId:
type: string
urlOrigin:
type: string
Connection:
type: object
required:
- id
- userId
- service
- accessToken
properties:
id:
type: string
userId:
type: string
service:
$ref: '#/components/schemas/Service'
accessToken:
type: string
refreshToken:
type: string
expiry:
type: number
ConnectionInfo:
type: object
required:
- connectionId
- serviceType
properties:
connectionId:
type: string
serviceType:
$ref: '#/components/schemas/serviceType'
username:
type: string
serverName:
type: string
profilePicture:
type: string
MediaItem:
type: object
required:
- connectionId
- serviceType
- type
- id
- name
properties:
connectionId:
type: string
serviceType:
$ref: '#/components/schemas/serviceType'
type:
type: string
enum: ['song', 'album', 'playlist', 'artist']
id:
type: string
name:
type: string
thumbnail:
type: string

57
src/app.d.ts vendored
View File

@@ -32,23 +32,21 @@ 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 {
connectionId: string
serviceType: serviceType
username: string
serverName?: string
profilePicture?: string
}
// These Schemas should only contain general info data that is necessary for data fetching purposes.
@@ -104,29 +102,16 @@ 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 JFConnectionInfo extends ConnectionInfo {
serviceType: 'jellyfin'
servername: string
}
interface User {
Name: string
Id: string
}
interface AuthData {
User: Jellyfin.User
AccessToken: string
}
interface System {
ServerName: string
}
@@ -182,27 +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
}
interface YTConnectionInfo extends ConnectionInfo {
serviceType: 'youtube-music'
profilePicture?: string
}
}
namespace YouTubeMusic {}
}
export {}

View File

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

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 })
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,25 +62,25 @@ 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
}
static getUserConnections = (userId: string): Connection[] => {
const connectionRows = db.prepare('SELECT * FROM Connections WHERE userId = ?').all(userId) as ConnectionsTableSchema[]
const connections: Connection[] = []
connectionRows.forEach((row) => {
const { id, service, tokens } = row
connections.push({ id, userId, service: JSON.parse(service), tokens: JSON.parse(tokens) })
})
for (const row of connectionRows) {
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)
}
@@ -78,4 +88,19 @@ export class Connections {
const commandInfo = db.prepare('DELETE FROM Connections WHERE id = ?').run(id)
if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
}
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
}
}

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 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

View File

@@ -0,0 +1,3 @@
import { google } from 'googleapis'
export class YouTubeMusic {}

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,60 +0,0 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/users'
import { google } from 'googleapis'
const jellyfinInfo = async (connection: Jellyfin.JFConnection): Promise<Jellyfin.JFConnectionInfo> => {
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,
}
}
const youtubeInfo = async (connection: YouTubeMusic.YTConnection): Promise<YouTubeMusic.YTConnectionInfo> => {
const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: connection.tokens.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 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 jellyfinInfo(connection as Jellyfin.JFConnection)
break
case 'youtube-music':
info = await youtubeInfo(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 { 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 })
}

View File

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

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 { 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 = await jellyfinAuthResponse.json()
const serviceData: Jellyfin.JFService = {
const serviceData: Service = {
type: 'jellyfin',
userId: authData.userId,
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 }
},

View File

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