FINALLY, Beautiful connection types!
This commit is contained in:
@@ -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.
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { google } from 'googleapis'
|
||||
|
||||
export class YouTubeMusic {}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user