FINALLY, Beautiful connection types!

This commit is contained in:
Eclypsed
2024-02-23 00:53:54 -05:00
parent c2236ab8ac
commit c7b9b214b7
16 changed files with 200 additions and 214 deletions

39
src/app.d.ts vendored
View File

@@ -4,7 +4,7 @@ declare global {
namespace App {
// interface Error {}
interface Locals {
user: Omit<User, 'password'>
user: Omit<User, 'passwordHash'>
}
// interface PageData {}
// interface PageState {}
@@ -21,25 +21,12 @@ declare global {
interface User {
id: string
username: string
password: string
passwordHash: string
}
type serviceType = 'jellyfin' | 'youtube-music'
type Service = Jellyfin.Service | YouTubeMusic.Service
type Tokens<T> = T extends Jellyfin.Service ? Jellyfin.Tokens : T extends YouTubeMusic.Service ? YouTubeMusic.Tokens : {}
// type ServiceTokenPair = [Jellyfin.Service, Jellyfin.Tokens] | [YouTubeMusic.Service, YouTubeMusic.Tokens]
interface BaseConnection<T> {
id: string
userId: string
type: T extends Jellyfin.Service ? 'jellyfin' : T extends YouTubeMusic.Service ? 'youtube-music' : serviceType
service: T extends undefined ? Service : T
}
type Connection<T extends Service = undefined> = BaseConnection<T> & Tokens<T>
type Connection<T extends serviceType> = T extends 'jellyfin' ? Jellyfin.Connection : T extends 'youtube-music' ? YouTubeMusic.Connection : never
// These Schemas should only contain general info data that is necessary for data fetching purposes.
// They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type.
@@ -94,16 +81,20 @@ 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 Service {
interface Connection {
id: string
userId: string
type: 'jellyfin'
service: {
userId: string
urlOrigin: string
username?: string
serverName?: string
}
interface Tokens {
tokens: {
accessToken: string
}
}
interface User {
Name: string
@@ -171,18 +162,22 @@ declare global {
}
namespace YouTubeMusic {
interface Service {
interface Connection {
id: string
userId: string
type: 'youtube-music'
service: {
userId: string
username?: string
profilePicture?: string
}
interface Tokens {
tokens: {
accessToken: string
refreshToken: string
expiry: number
}
}
}
}
export {}

View File

@@ -17,7 +17,7 @@ export const handle: Handle = async ({ event, resolve }) => {
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
try {
const tokenData = jwt.verify(authToken, SECRET_JWT_KEY) as Omit<User, 'password'>
const tokenData = jwt.verify(authToken, SECRET_JWT_KEY) as Omit<User, 'passwordHash'>
event.locals.user = tokenData
} catch {
throw redirect(303, `/login?redirect=${urlpath}`)
@@ -33,7 +33,7 @@ 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) {
switch (connection.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', {
@@ -41,13 +41,13 @@ export const handleFetch: HandleFetch = async ({ request, fetch, event }) => {
body: JSON.stringify({
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
client_secret: YOUTUBE_API_CLIENT_SECRET,
refresh_token: connection.refreshToken as string,
refresh_token: connection.tokens.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)
Connections.updateTokens(connection.id, access_token, connection.tokens.refreshToken, newExpiry)
console.log('Refreshed YouTubeMusic access token')
}
}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
export let mediaItem: MediaItem
import Services from '$lib/services.json'
import IconButton from '$lib/components/util/iconButton.svelte'
import { goto } from '$app/navigation'

Binary file not shown.

View File

@@ -1,22 +1,19 @@
import Database, { SqliteError } from 'better-sqlite3'
import Database from 'better-sqlite3'
import { generateUUID } from '$lib/utils'
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
passwordHash VARCHAR(72) NOT NULL
)`
const initConnectionsTable = `CREATE TABLE IF NOT EXISTS Connections(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
type VARCHAR(36) NOT NULL,
service TEXT NOT NULL,
accessToken TEXT NOT NULL,
refreshToken TEXT,
expiry NUMBER,
service TEXT,
tokens TEXT
FOREIGN KEY(userId) REFERENCES Users(id)
)`
db.exec(initUsersTable), db.exec(initConnectionsTable)
@@ -24,88 +21,87 @@ db.exec(initUsersTable), db.exec(initConnectionsTable)
interface ConnectionsTableSchema {
id: string
userId: string
type: string
service: string
accessToken: string
refreshToken?: string
expiry?: number
type: serviceType
service?: string
tokens?: string
}
export class Users {
static getUser = (id: string): User | null => {
const user = db.prepare('SELECT * FROM Users WHERE id = ?').get(id) as User | null
const user = db.prepare(`SELECT * FROM Users WHERE id = ?`).get(id) as User | null
return user
}
static getUsername = (username: string): User | null => {
const user = db.prepare('SELECT * FROM Users WHERE lower(username) = ?').get(username.toLowerCase()) as User | null
const user = db.prepare(`SELECT * FROM Users WHERE lower(username) = ?`).get(username.toLowerCase()) as User | null
return user
}
static addUser = (username: string, hashedPassword: string): User | null => {
static addUser = (username: string, passwordHash: string): User | null => {
if (this.getUsername(username)) return null
const userId = generateUUID()
db.prepare('INSERT INTO Users(id, username, password) VALUES(?, ?, ?)').run(userId, username, hashedPassword)
db.prepare(`INSERT INTO Users(id, username, passwordHash) VALUES(?, ?, ?)`).run(userId, username, passwordHash)
return this.getUser(userId)!
}
static deleteUser = (id: string): void => {
const commandInfo = db.prepare('DELETE FROM Users WHERE id = ?').run(id)
const commandInfo = db.prepare(`DELETE FROM Users WHERE id = ?`).run(id)
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
}
}
type DBConnectionData<T extends serviceType> = T extends 'jellyfin'
? Omit<Jellyfin.Connection, 'service'> & { service: Pick<Jellyfin.Connection['service'], 'userId' | 'urlOrigin'> }
: T extends 'youtube-music'
? Omit<YouTubeMusic.Connection, 'service'> & { service: Pick<YouTubeMusic.Connection['service'], 'userId'> }
: never
export class Connections {
static getConnection = (id: string): BaseConnection => {
const { userId, service, accessToken, refreshToken, expiry } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as ConnectionsTableSchema
const connection: BaseConnection = { id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry }
static getConnection = (id: string): DBConnectionData<serviceType> => {
const { userId, type, service, tokens } = db.prepare(`SELECT * FROM Connections WHERE id = ?`).get(id) as ConnectionsTableSchema
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
const connection: DBConnectionData<typeof type> = { id, userId, type, service: parsedService, tokens: parsedTokens }
return connection
}
static getUserConnections = (userId: string): Connection[] => {
const connectionRows = db.prepare('SELECT * FROM Connections WHERE userId = ?').all(userId) as ConnectionsTableSchema[]
const connections: Connection[] = []
for (const row of connectionRows) {
const { id, service, accessToken, refreshToken, expiry } = row
connections.push({ id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry })
static getUserConnections = (userId: string): DBConnectionData<serviceType>[] => {
const connectionRows = db.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as ConnectionsTableSchema[]
const connections: DBConnectionData<serviceType>[] = []
for (const { id, type, service, tokens } of connectionRows) {
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
connections.push({ id, userId, type, service: parsedService, tokens: parsedTokens })
}
return connections
}
static addConnection = (userId: string, service: Service, accessToken?: string, refreshToken?: string, expiry?: number): Connection => {
static addConnection<T extends serviceType>(type: T, connectionData: Omit<DBConnectionData<T>, 'id' | 'type'>): DBConnectionData<T> {
const connectionId = generateUUID()
const test: Connection = {
id: 'test',
userId: 'test',
type: 'jellyfin',
service: {
userId: 'test',
urlOrigin: 'test',
},
accessToken: 'test',
}
// if (!isValidURL(service.urlOrigin)) throw new Error('Service does not have valid url')
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)
const { userId, service, tokens } = connectionData
db.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, service, tokens)
return this.getConnection(connectionId) as DBConnectionData<T>
}
static deleteConnection = (id: string): void => {
const commandInfo = db.prepare('DELETE FROM Connections WHERE id = ?').run(id)
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)
static updateTokens = (id: string, accessToken?: string, refreshToken?: string, expiry?: number): void => {
const newTokens = { accessToken, refreshToken, expiry }
const commandInfo = db.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(newTokens, 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 })
static getExpiredConnections = (userId: string): DBConnectionData<serviceType>[] => {
const expiredRows = db.prepare(`SELECT * FROM Connections WHERE userId = ? AND json_extract(tokens, '$.expiry') < ?`).all(userId, Date.now()) as ConnectionsTableSchema[]
const connections: DBConnectionData<serviceType>[] = []
for (const { id, userId, type, service, tokens } of expiredRows) {
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
connections.push({ id, userId, type, service: parsedService, tokens: parsedTokens })
}
return connections
}

View File

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

View File

@@ -1,14 +0,0 @@
{
"jellyfin": {
"displayName": "Jellyfin",
"type": ["streaming"],
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg",
"primaryColor": "--jellyfin-blue"
},
"youtube-music": {
"displayName": "YouTube Music",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg",
"primaryColor": "--youtube-red"
}
}

View File

@@ -1,3 +1,20 @@
import { google } from 'googleapis'
export const serviceData = {
jellyfin: {
displayName: 'Jellyfin',
type: ['streaming'],
icon: 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg',
primaryColor: '--jellyfin-blue',
},
'youtube-music': {
displayName: 'YouTube Music',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg',
primaryColor: '--youtube-red',
},
}
export class Jellyfin {
static audioPresets = (userId: string) => {
return {
@@ -10,8 +27,28 @@ export class Jellyfin {
}
}
static songFactory = (song: Jellyfin.Song, connection: Connection): Song => {
const { id, service } = connection
static fetchSerivceInfo = async (userId: string, urlOrigin: string, accessToken: string): Promise<Connection<'jellyfin'>['service']> => {
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
const userUrl = new URL(`Users/${userId}`, urlOrigin).href
const systemUrl = new URL('System/Info', 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 {
userId,
urlOrigin,
username: userData.Name,
serverName: systemData.ServerName,
}
}
static songFactory = (song: Jellyfin.Song, connection: Connection<'jellyfin'>): Song => {
const { id, type, service } = connection
const thumbnail = song.ImageTags?.Primary
? new URL(`Items/${song.Id}/Images/Primary`, service.urlOrigin).href
: song.AlbumPrimaryImageTag
@@ -29,7 +66,7 @@ export class Jellyfin {
return {
connectionId: id,
serviceType: service.type,
serviceType: type,
type: 'song',
id: song.Id,
name: song.Name,
@@ -42,8 +79,8 @@ export class Jellyfin {
}
}
static albumFactory = (album: Jellyfin.Album, connection: Connection): Album => {
const { id, service } = connection
static albumFactory = (album: Jellyfin.Album, connection: Connection<'jellyfin'>): Album => {
const { id, type, service } = connection
const thumbnail = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, service.urlOrigin).href : undefined
const albumArtists = album.AlbumArtists
@@ -60,7 +97,7 @@ export class Jellyfin {
return {
connectionId: id,
serviceType: service.type,
serviceType: type,
type: 'album',
id: album.Id,
name: album.Name,
@@ -72,13 +109,13 @@ export class Jellyfin {
}
}
static playListFactory = (playlist: Jellyfin.Playlist, connection: Connection): Playlist => {
const { id, service } = connection
static playListFactory = (playlist: Jellyfin.Playlist, connection: Connection<'jellyfin'>): Playlist => {
const { id, type, service } = connection
const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, service.urlOrigin).href : undefined
return {
connectionId: id,
serviceType: service.type,
serviceType: type,
type: 'playlist',
id: playlist.Id,
name: playlist.Name,
@@ -87,13 +124,13 @@ export class Jellyfin {
}
}
static artistFactory = (artist: Jellyfin.Artist, connection: Connection): Artist => {
const { id, service } = connection
static artistFactory = (artist: Jellyfin.Artist, connection: Connection<'jellyfin'>): Artist => {
const { id, type, service } = connection
const thumbnail = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, service.urlOrigin).href : undefined
return {
connectionId: id,
serviceType: service.type,
serviceType: type,
type: 'artist',
id: artist.Id,
name: artist.Name,
@@ -101,3 +138,17 @@ export class Jellyfin {
}
}
}
export class YouTubeMusic {
static fetchServiceInfo = async (userId: string, accessToken: string): Promise<Connection<'youtube-music'>['service']> => {
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 {
userId,
username: userChannel.snippet?.title as string,
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
}
}
}

View File

@@ -1,12 +1,23 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Jellyfin, YouTubeMusic } from '$lib/services'
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))
const connections: Connection<serviceType>[] = []
for (const connectionId of ids) {
const connection = Connections.getConnection(connectionId)
switch (connection.type) {
case 'jellyfin':
connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken)
break
case 'youtube-music':
connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken)
break
}
}
return Response.json({ connections })
}

View File

@@ -1,53 +0,0 @@
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,9 +1,21 @@
import { Connections } from '$lib/server/users'
import { Jellyfin, YouTubeMusic } from '$lib/services'
import type { RequestHandler } from '@sveltejs/kit'
export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId!
const connections = Connections.getUserConnections(userId)
for (const connection of connections) {
switch (connection.type) {
case 'jellyfin':
connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken)
break
case 'youtube-music':
connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken)
break
}
}
return Response.json({ connections })
}

View File

@@ -1,6 +1,6 @@
import type { RequestHandler } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import { Jellyfin } from '$lib/service-managers/jellyfin'
import { Jellyfin } from '$lib/services'
// This is temporary functionally for the sake of developing the app.
// In the future will implement more robust algorithm for offering recommendations
@@ -13,9 +13,9 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
const recommendations: MediaItem[] = []
for (const connection of userConnections.connections) {
const { service, accessToken } = connection as Connection
const { type, service, tokens } = connection as Connection<serviceType>
switch (service.type) {
switch (type) {
case 'jellyfin':
const mostPlayedSongsSearchParams = new URLSearchParams({
SortBy: 'PlayCount',
@@ -26,7 +26,7 @@ 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="${accessToken}"` })
const requestHeaders = new Headers({ Authorization: `MediaBrowser Token="${tokens.accessToken}"` })
const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: requestHeaders })
const mostPlayedData = await mostPlayedResponse.json()

View File

@@ -18,7 +18,7 @@ export const actions: Actions = {
const user = Users.getUsername(username.toString())
if (!user) return fail(400, { message: 'Invalid Username' })
const passwordValid = await compare(password.toString(), user.password)
const passwordValid = await compare(password.toString(), user.passwordHash)
if (!passwordValid) return fail(400, { message: 'Invalid Password' })
const authToken = jwt.sign({ id: user.id, username: user.username }, SECRET_JWT_KEY, { expiresIn: '100d' })

View File

@@ -43,13 +43,11 @@ export const actions: Actions = {
return fail(400, { message: 'Could not reach Jellyfin server' })
}
const serviceData: Service = {
type: 'jellyfin',
userId: authData.User.Id,
urlOrigin: serverUrl.toString(),
}
const newConnection = Connections.addConnection(locals.user.id, serviceData, authData.AccessToken)
const newConnection = Connections.addConnection('jellyfin', {
userId: locals.user.id,
service: { userId: authData.User.Id, urlOrigin: serverUrl.toString() },
tokens: { accessToken: authData.AccessToken },
})
return { newConnection }
},
@@ -63,13 +61,11 @@ export const actions: Actions = {
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! })
const userChannel = userChannelResponse.data.items![0]
const serviceData: Service = {
type: 'youtube-music',
userId: userChannel.id!,
urlOrigin: 'https://www.googleapis.com/youtube/v3',
}
const newConnection = Connections.addConnection(locals.user.id, serviceData, tokens.access_token!, tokens.refresh_token!, tokens.expiry_date!)
const newConnection = Connections.addConnection('youtube-music', {
userId: locals.user.id,
service: { userId: userChannel.id! },
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
})
return { newConnection }
},

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import Services from '$lib/services.json'
import { serviceData } from '$lib/services'
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/stores.js'
import type { PageServerData } from './$types.js'
@@ -11,7 +11,7 @@
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
export let data: PageServerData
let connections: Connection[] = data.connections
let connections: Connection<serviceType>[] = data.connections
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
const { serverUrl, username, password } = Object.fromEntries(formData)
@@ -34,11 +34,11 @@
if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message])
} else if (result.type === 'success') {
const newConnection: Connection = result.data!.newConnection
const newConnection: Connection<'jellyfin'> = result.data!.newConnection
connections = [...connections, newConnection]
newConnectionModal = null
return ($newestAlert = ['success', `Added ${Services[newConnection.service.type].displayName}`])
return ($newestAlert = ['success', `Added ${serviceData[newConnection.type].displayName}`])
}
}
}
@@ -67,7 +67,7 @@
if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message])
} else if (result.type === 'success') {
const newConnection: Connection = result.data!.newConnection
const newConnection: Connection<'youtube-music'> = result.data!.newConnection
connections = [...connections, newConnection]
return ($newestAlert = ['success', 'Added Youtube Music'])
}
@@ -84,12 +84,12 @@
} else if (result.type === 'success') {
const id = result.data!.deletedConnectionId
const indexToDelete = connections.findIndex((connection) => connection.id === id)
const serviceType = connections[indexToDelete].service.type
const serviceType = connections[indexToDelete].type
connections.splice(indexToDelete, 1)
connections = connections
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
return ($newestAlert = ['success', `Deleted ${serviceData[serviceType].displayName}`])
}
}
}
@@ -102,11 +102,11 @@
<h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4">
<button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
<img src={Services.jellyfin.icon} alt="{Services.jellyfin.displayName} icon" class="aspect-square h-full p-2" />
<img src={serviceData.jellyfin.icon} alt="{serviceData.jellyfin.displayName} icon" class="aspect-square h-full p-2" />
</button>
<form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}>
<button class="add-connection-button h-14 rounded-md">
<img src={Services['youtube-music'].icon} alt="{Services['youtube-music'].displayName} icon" class="aspect-square h-full p-2" />
<img src={serviceData['youtube-music'].icon} alt="{serviceData['youtube-music'].displayName} icon" class="aspect-square h-full p-2" />
</button>
</form>
</div>

View File

@@ -1,47 +1,43 @@
<script lang="ts">
import Services from '$lib/services.json'
import { serviceData } from '$lib/services'
import IconButton from '$lib/components/util/iconButton.svelte'
import Toggle from '$lib/components/util/toggle.svelte'
import type { SubmitFunction } from '@sveltejs/kit'
import { fly } from 'svelte/transition'
import { enhance } from '$app/forms'
export let connection: Connection
export let connection: Connection<serviceType>
export let submitFunction: SubmitFunction
$: serviceData = Services[connection.service.type]
$: reactiveServiceData = serviceData[connection.type]
let showModal = false
</script>
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
<header class="flex h-20 items-center gap-4 p-4">
<div class="h-full aspect-square p-1 relative">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" />
<div class="relative aspect-square h-full p-1">
<img src={reactiveServiceData.icon} alt="{reactiveServiceData.displayName} icon" />
{#if 'profilePicture' in connection.service && typeof connection.service.profilePicture === 'string'}
<img src="{connection.service.profilePicture}" alt="" class="absolute h-5 aspect-square bottom-0 right-0 rounded-full"/>
<img src={connection.service.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
{/if}
</div>
<div>
<div>Username</div>
<div class="text-sm text-neutral-500">
{serviceData.displayName}
{reactiveServiceData.displayName}
{#if 'serverName' in connection.service}
- {connection.service.serverName}
{/if}
</div>
</div>
<div class="relative ml-auto h-8 flex flex-row-reverse gap-2">
<IconButton halo={true} on:click={() => showModal = !showModal}>
<div class="relative ml-auto flex h-8 flex-row-reverse gap-2">
<IconButton halo={true} on:click={() => (showModal = !showModal)}>
<i slot="icon" class="fa-solid fa-ellipsis-vertical text-xl text-neutral-500" />
</IconButton>
{#if showModal}
<form
use:enhance={submitFunction}
method="post"
class="absolute right-0 top-full flex flex-col items-center justify-center gap-1 rounded-md bg-neutral-900 p-2 text-xs"
>
<button formaction="?/deleteConnection" class="py-2 px-3 whitespace-nowrap hover:bg-neutral-800 rounded-md">
<form use:enhance={submitFunction} method="post" class="absolute right-0 top-full flex flex-col items-center justify-center gap-1 rounded-md bg-neutral-900 p-2 text-xs">
<button formaction="?/deleteConnection" class="whitespace-nowrap rounded-md px-3 py-2 hover:bg-neutral-800">
<i class="fa-solid fa-link-slash mr-1" />
Delete Connection
</button>