Either google apis is the most scuffed thing ever, or it's too genius for my puny mind
This commit is contained in:
21
src/app.d.ts
vendored
21
src/app.d.ts
vendored
@@ -27,11 +27,17 @@ declare global {
|
||||
urlOrigin: string
|
||||
}
|
||||
|
||||
interface Tokens {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
expiry?: number
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
id: string
|
||||
user: User
|
||||
service: Service
|
||||
accessToken: string
|
||||
tokens: Tokens
|
||||
}
|
||||
|
||||
// These Schemas should only contain general info data that is necessary for data fetching purposes.
|
||||
@@ -93,8 +99,13 @@ declare global {
|
||||
serverName: string
|
||||
}
|
||||
|
||||
interface JFTokens implements Tokens {
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
interface JFConnection extends Connection {
|
||||
service: JFService
|
||||
tokens: JFTokens
|
||||
}
|
||||
|
||||
interface AuthData {
|
||||
@@ -168,10 +179,18 @@ declare global {
|
||||
interface YTService extends Service {
|
||||
type: 'youtube-music'
|
||||
username: string
|
||||
profilePicture?: string
|
||||
}
|
||||
|
||||
interface YTTokens implements Tokens {
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expiry: number
|
||||
}
|
||||
|
||||
interface YTConnection extends Connection {
|
||||
service: YTService
|
||||
tokens: YTTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
src/app.html
19
src/app.html
@@ -1,12 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
@@ -5,7 +5,7 @@ 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, accessToken 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, tokens TEXT NOT NULL, FOREIGN KEY(userId) REFERENCES Users(id))'
|
||||
db.exec(initUsersTable), db.exec(initConnectionsTable)
|
||||
|
||||
type UserQueryParams = {
|
||||
@@ -16,7 +16,7 @@ interface ConnectionsTableSchema {
|
||||
id: string
|
||||
userId: string
|
||||
service: string
|
||||
accessToken: string
|
||||
tokens: string
|
||||
}
|
||||
|
||||
export class Users {
|
||||
@@ -52,8 +52,8 @@ export class Users {
|
||||
|
||||
export class Connections {
|
||||
static getConnection = (id: string): Connection => {
|
||||
const { userId, service, accessToken } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as ConnectionsTableSchema
|
||||
const connection: Connection = { id, user: Users.getUser(userId)!, service: JSON.parse(service), accessToken }
|
||||
const { userId, service, tokens } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as ConnectionsTableSchema
|
||||
const connection: Connection = { id, user: Users.getUser(userId)!, service: JSON.parse(service), tokens: JSON.parse(tokens) }
|
||||
return connection
|
||||
}
|
||||
|
||||
@@ -62,16 +62,16 @@ export class Connections {
|
||||
const connections: Connection[] = []
|
||||
const user = Users.getUser(userId)!
|
||||
connectionRows.forEach((row) => {
|
||||
const { id, service, accessToken } = row
|
||||
connections.push({ id, user, service: JSON.parse(service), accessToken })
|
||||
const { id, service, tokens } = row
|
||||
connections.push({ id, user, service: JSON.parse(service), tokens: JSON.parse(tokens) })
|
||||
})
|
||||
return connections
|
||||
}
|
||||
|
||||
static addConnection = (userId: string, service: Service, accessToken: string): Connection => {
|
||||
static addConnection = (userId: string, service: Service, tokens: Tokens): 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, accessToken) VALUES(?, ?, ?, ?)').run(connectionId, userId, JSON.stringify(service), accessToken)
|
||||
db.prepare('INSERT INTO Connections(id, userId, service, tokens) VALUES(?, ?, ?, ?)').run(connectionId, userId, JSON.stringify(service), JSON.stringify(tokens))
|
||||
return this.getConnection(connectionId)
|
||||
}
|
||||
|
||||
|
||||
4
src/routes/(app)/search/+page.server.ts
Normal file
4
src/routes/(app)/search/+page.server.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { PageServerLoad } from '../$types'
|
||||
import ytdl from 'ytdl-core'
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {}
|
||||
@@ -1 +1 @@
|
||||
<h1>Welcome to the Search Page!</h1>
|
||||
<h1>Search Page</h1>
|
||||
|
||||
@@ -17,7 +17,11 @@ const newConnectionSchema = z.object({
|
||||
userId: z.string(),
|
||||
urlOrigin: z.string().refine((val) => isValidURL(val)),
|
||||
}),
|
||||
accessToken: z.string(),
|
||||
tokens: z.object({
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string().optional(),
|
||||
expiry: z.number().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
@@ -28,8 +32,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const connectionValidation = newConnectionSchema.safeParse(connection)
|
||||
if (!connectionValidation.success) return new Response(connectionValidation.error.message, { status: 400 })
|
||||
|
||||
const { service, accessToken } = connection
|
||||
const newConnection = Connections.addConnection(userId, service, accessToken)
|
||||
const { service, tokens } = connection
|
||||
const newConnection = Connections.addConnection(userId, service, tokens)
|
||||
return Response.json(newConnection)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
|
||||
const recommendations: Song[] = []
|
||||
|
||||
for (const connection of userConnections) {
|
||||
const { service, accessToken } = connection
|
||||
const { service, tokens } = connection
|
||||
|
||||
switch (service.type) {
|
||||
case 'jellyfin':
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fail } from '@sveltejs/kit'
|
||||
import { SECRET_INTERNAL_API_KEY } 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 type { PageServerLoad, Actions } from './$types'
|
||||
import { google } from 'googleapis'
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
const connectionsResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
|
||||
@@ -53,10 +55,13 @@ export const actions: Actions = {
|
||||
username: userData.Name,
|
||||
serverName: systemData.ServerName,
|
||||
}
|
||||
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, accessToken: authData.AccessToken }),
|
||||
body: JSON.stringify({ service: serviceData, tokens: tokenData }),
|
||||
})
|
||||
|
||||
if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
|
||||
@@ -64,6 +69,30 @@ export const actions: Actions = {
|
||||
const newConnection: Jellyfin.JFConnection = await newConnectionResponse.json()
|
||||
return { newConnection }
|
||||
},
|
||||
youtubeMusicLogin: async ({ request }) => {
|
||||
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 userChannel = userChannelResponse.data.items![0]
|
||||
|
||||
const serviceData: YouTubeMusic.YTService = {
|
||||
type: 'youtube-music',
|
||||
userId: userChannel.id as string,
|
||||
urlOrigin: 'https://www.googleapis.com/youtube/v3',
|
||||
username: userChannel.snippet?.title as string,
|
||||
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
|
||||
}
|
||||
},
|
||||
deleteConnection: async ({ request, fetch, locals }) => {
|
||||
const formData = await request.formData()
|
||||
const connectionId = formData.get('connectionId')
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
import { getDeviceUUID } from '$lib/utils'
|
||||
import { SvelteComponent, type ComponentType } from 'svelte'
|
||||
import ConnectionProfile from './connectionProfile.svelte'
|
||||
import { enhance } from '$app/forms'
|
||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||
|
||||
export let data: PageServerData
|
||||
let connections = data.userConnections
|
||||
|
||||
const submitCredentials: SubmitFunction = ({ formData, action, cancel }) => {
|
||||
const submitCredentials: SubmitFunction = async ({ formData, action, cancel }) => {
|
||||
switch (action.search) {
|
||||
case '?/authenticateJellyfin':
|
||||
const { serverUrl, username, password } = Object.fromEntries(formData)
|
||||
@@ -30,9 +32,14 @@
|
||||
const deviceId = getDeviceUUID()
|
||||
formData.append('deviceId', deviceId)
|
||||
break
|
||||
case '?/youtubeMusicLogin':
|
||||
const code = await youtubeAuthenication()
|
||||
formData.append('code', code)
|
||||
break
|
||||
case '?/deleteConnection':
|
||||
break
|
||||
default:
|
||||
console.log(action.search)
|
||||
cancel()
|
||||
}
|
||||
|
||||
@@ -56,11 +63,28 @@
|
||||
|
||||
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
|
||||
}
|
||||
} else if (result.type === 'redirect') {
|
||||
window.open(result.location, '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newConnectionModal: ComponentType<SvelteComponent<{ submitFunction: SubmitFunction }>> | null = null
|
||||
|
||||
const youtubeAuthenication = async (): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
// @ts-ignore (google variable is a global variable imported by html script tag)
|
||||
const client = google.accounts.oauth2.initCodeClient({
|
||||
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
|
||||
scope: 'https://www.googleapis.com/auth/youtube',
|
||||
ux_mode: 'popup',
|
||||
callback: (response: any) => {
|
||||
resolve(response.code)
|
||||
},
|
||||
})
|
||||
client.requestCode()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
@@ -70,9 +94,11 @@
|
||||
<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" />
|
||||
</button>
|
||||
<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" />
|
||||
</button>
|
||||
<form method="post" action="?/youtubeMusicLogin" use:enhance={submitCredentials}>
|
||||
<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" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<div class="grid gap-8">
|
||||
|
||||
Reference in New Issue
Block a user