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

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