Moved connections to user page, began search functionality
This commit is contained in:
8
src/app.d.ts
vendored
8
src/app.d.ts
vendored
@@ -41,9 +41,10 @@ declare global {
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface Connection {
|
interface Connection {
|
||||||
|
public id: string
|
||||||
getRecommendations: () => Promise<(Song | Album | Playlist)[]>
|
getRecommendations: () => Promise<(Song | Album | Playlist)[]>
|
||||||
getConnectionInfo: () => Promise<ConnectionInfo>
|
getConnectionInfo: () => Promise<ConnectionInfo>
|
||||||
search: (searchTerm: string) => Promise<(Song | Album | Playlist)[]>
|
search: (searchTerm: string) => Promise<(Song | Album | Artist | Playlist)[]>
|
||||||
}
|
}
|
||||||
// These Schemas should only contain general info data that is necessary for data fetching purposes.
|
// 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.
|
// 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.
|
||||||
@@ -90,17 +91,12 @@ declare global {
|
|||||||
releaseDate?: string
|
releaseDate?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMPORTANT: This interface is for Lazuli created and stored playlists. Use service-specific interfaces when pulling playlists from services
|
|
||||||
type Playlist = {
|
type Playlist = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'playlist'
|
type: 'playlist'
|
||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
description?: string
|
description?: string
|
||||||
items: {
|
|
||||||
connectionId: string
|
|
||||||
id: string
|
|
||||||
}[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Artist = {
|
type Artist = {
|
||||||
|
|||||||
31
src/lib/components/util/searchBar.svelte
Normal file
31
src/lib/components/util/searchBar.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
let searchBar: HTMLElement, searchInput: HTMLInputElement
|
||||||
|
|
||||||
|
const triggerSearch = (searchQuery: string) => goto(`/search?query=${searchQuery}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<search role="search" bind:this={searchBar} class="relative flex h-12 w-full items-center gap-2.5 justify-self-center rounded-full border-2 border-transparent px-4 py-2" style="background-color: rgba(82, 82, 82, 0.25);">
|
||||||
|
<button
|
||||||
|
class="aspect-square h-6 transition-colors duration-200 hover:text-lazuli-primary"
|
||||||
|
on:click|preventDefault={() => {
|
||||||
|
if (searchInput.value.trim() !== '') {
|
||||||
|
triggerSearch(searchInput.value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-magnifying-glass" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
bind:this={searchInput}
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
class="w-full bg-transparent outline-none"
|
||||||
|
placeholder="Let's find some music"
|
||||||
|
autocomplete="off"
|
||||||
|
on:keypress={(event) => {
|
||||||
|
if (event.key === 'Enter') triggerSearch(searchInput.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</search>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export class Jellyfin implements Connection {
|
export class Jellyfin implements Connection {
|
||||||
private id: string
|
public id: string
|
||||||
private userId: string
|
private userId: string
|
||||||
private jfUserId: string
|
private jfUserId: string
|
||||||
private serverUrl: string
|
private serverUrl: string
|
||||||
@@ -72,21 +72,23 @@ export class Jellyfin implements Connection {
|
|||||||
public search = async (searchTerm: string): Promise<(Song | Album | Playlist)[]> => {
|
public search = async (searchTerm: string): Promise<(Song | Album | Playlist)[]> => {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
searchTerm,
|
searchTerm,
|
||||||
includeItemTypes: 'Audio,MusicAlbum', // Potentially add MusicArtist
|
includeItemTypes: 'Audio,MusicAlbum,Playlist', // Potentially add MusicArtist
|
||||||
recursive: 'true',
|
recursive: 'true',
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl).toString()
|
const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl).toString()
|
||||||
const searchResponse = await fetch(searchURL, { headers: this.BASEHEADERS })
|
const searchResponse = await fetch(searchURL, { headers: this.BASEHEADERS })
|
||||||
if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL)
|
if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL)
|
||||||
const searchResults = (await searchResponse.json()).Items as (JellyfinAPI.Song | JellyfinAPI.Album)[] // JellyfinAPI.Artist
|
const searchResults = (await searchResponse.json()).Items as (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Playlist)[] // JellyfinAPI.Artist
|
||||||
|
|
||||||
const parsedResults: (Song | Album)[] = Array.from(searchResults, (result) => {
|
const parsedResults: (Song | Album | Playlist)[] = Array.from(searchResults, (result) => {
|
||||||
switch (result.Type) {
|
switch (result.Type) {
|
||||||
case 'Audio':
|
case 'Audio':
|
||||||
return this.parseSong(result)
|
return this.parseSong(result)
|
||||||
case 'MusicAlbum':
|
case 'MusicAlbum':
|
||||||
return this.parseAlbum(result)
|
return this.parseAlbum(result)
|
||||||
|
case 'Playlist':
|
||||||
|
return this.parsePlaylist(result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return parsedResults
|
return parsedResults
|
||||||
@@ -147,6 +149,17 @@ export class Jellyfin implements Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parsePlaylist = (playlist: JellyfinAPI.Playlist): Playlist => {
|
||||||
|
const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, this.serverUrl).toString() : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: playlist.Id,
|
||||||
|
name: playlist.Name,
|
||||||
|
type: 'playlist',
|
||||||
|
thumbnail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static authenticateByName = async (username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthData> => {
|
public static authenticateByName = async (username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthData> => {
|
||||||
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString()
|
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString()
|
||||||
return fetch(authUrl, {
|
return fetch(authUrl, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
|||||||
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
||||||
|
|
||||||
export class YouTubeMusic implements Connection {
|
export class YouTubeMusic implements Connection {
|
||||||
private id: string
|
public id: string
|
||||||
private userId: string
|
private userId: string
|
||||||
private ytUserId: string
|
private ytUserId: string
|
||||||
private tokens: YouTubeMusic.Tokens
|
private tokens: YouTubeMusic.Tokens
|
||||||
@@ -77,23 +77,25 @@ export class YouTubeMusic implements Connection {
|
|||||||
public search = async (searchTerm: string): Promise<(Song | Album | Playlist)[]> => {
|
public search = async (searchTerm: string): Promise<(Song | Album | Playlist)[]> => {
|
||||||
const headers = Object.assign(this.BASEHEADERS, { authorization: `Bearer ${(await this.getTokens()).accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` })
|
const headers = Object.assign(this.BASEHEADERS, { authorization: `Bearer ${(await this.getTokens()).accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` })
|
||||||
|
|
||||||
const response = await fetch(`https://music.youtube.com/youtubei/v1/search`, {
|
// const response = await fetch(`https://music.youtube.com/youtubei/v1/search`, {
|
||||||
headers,
|
// headers,
|
||||||
method: 'POST',
|
// method: 'POST',
|
||||||
body: JSON.stringify({
|
// body: JSON.stringify({
|
||||||
query: searchTerm,
|
// query: searchTerm,
|
||||||
context: {
|
// context: {
|
||||||
client: {
|
// client: {
|
||||||
clientName: 'WEB_REMIX',
|
// clientName: 'WEB_REMIX',
|
||||||
clientVersion: `1.${formatDate()}.01.00`,
|
// clientVersion: `1.${formatDate()}.01.00`,
|
||||||
hl: 'en',
|
// hl: 'en',
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
}),
|
// }),
|
||||||
})
|
// })
|
||||||
|
|
||||||
const data = await response.json()
|
// const data = await response.json()
|
||||||
console.log(JSON.stringify(data))
|
// console.log(JSON.stringify(data))
|
||||||
|
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHome = async (): Promise<YouTubeMusic.HomeItems> => {
|
private getHome = async (): Promise<YouTubeMusic.HomeItems> => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pageWidth } from '$lib/stores'
|
import { pageWidth } from '$lib/stores'
|
||||||
|
import SearchBar from '$lib/components/util/searchBar.svelte'
|
||||||
import type { LayoutData } from './$types'
|
import type { LayoutData } from './$types'
|
||||||
import NavTab from '$lib/components/navbar/navTab.svelte'
|
import NavTab from '$lib/components/navbar/navTab.svelte'
|
||||||
import PlaylistTab from '$lib/components/navbar/playlistTab.svelte'
|
import PlaylistTab from '$lib/components/navbar/playlistTab.svelte'
|
||||||
@@ -21,8 +22,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $pageWidth >= 768}
|
{#if $pageWidth >= 768}
|
||||||
<div class="grid h-full grid-rows-1 gap-8 overflow-hidden">
|
<div class="h-full overflow-hidden">
|
||||||
<div class="no-scrollbar fixed left-0 top-0 z-10 grid h-full w-20 grid-cols-1 grid-rows-[min-content_auto] gap-5 px-3 py-16">
|
<div class="no-scrollbar fixed left-0 top-0 z-10 grid h-full w-20 grid-cols-1 grid-rows-[min-content_auto] gap-5 px-3 py-12">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{#each data.navTabs as nav}
|
{#each data.navTabs as nav}
|
||||||
<NavTab {nav} disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)} />
|
<NavTab {nav} disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)} />
|
||||||
@@ -38,7 +39,10 @@
|
|||||||
<div class="overflow-clip text-ellipsis text-neutral-400">Playlist • {data.user.username}</div>
|
<div class="overflow-clip text-ellipsis text-neutral-400">Playlist • {data.user.username}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section class="no-scrollbar overflow-y-scroll px-[max(7rem,_7vw)] pt-16">
|
<section class="no-scrollbar overflow-y-scroll px-[max(7rem,_7vw)]">
|
||||||
|
<div class="my-6 max-w-xl">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">
|
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import type { PageServerLoad } from '../$types'
|
import type { PageServerLoad } from '../$types'
|
||||||
import ytdl from 'ytdl-core'
|
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {}
|
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||||
|
const query = url.searchParams.get('query')
|
||||||
|
if (query) {
|
||||||
|
const searchResults: { searchResults: (Song | Album | Artist | Playlist)[] } = await fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||||
|
}).then((response) => response.json())
|
||||||
|
|
||||||
|
return searchResults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,11 @@
|
|||||||
<h1>Search Page</h1>
|
<script lang="ts">
|
||||||
|
import type { PageServerData } from './$types'
|
||||||
|
|
||||||
|
export let data: PageServerData
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if data.searchResults}
|
||||||
|
{#each data.searchResults as searchResult}
|
||||||
|
<div>{searchResult.name} - {searchResult.type}</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -2,8 +2,101 @@
|
|||||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import type { LayoutData } from '../$types'
|
import type { LayoutData } from '../$types'
|
||||||
|
import Services from '$lib/services.json'
|
||||||
|
import JellyfinIcon from '$lib/static/jellyfin-icon.svg'
|
||||||
|
import YouTubeMusicIcon from '$lib/static/youtube-music-icon.svg'
|
||||||
|
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
|
||||||
|
import { newestAlert } from '$lib/stores.js'
|
||||||
|
import type { PageServerData } from './$types.js'
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit'
|
||||||
|
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: LayoutData
|
export let data: PageServerData & LayoutData
|
||||||
|
let connections: ConnectionInfo[] = data.connections
|
||||||
|
|
||||||
|
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
|
||||||
|
const { serverUrl, username, password } = Object.fromEntries(formData)
|
||||||
|
|
||||||
|
if (!(serverUrl && username && password)) {
|
||||||
|
$newestAlert = ['caution', 'All fields must be filled out']
|
||||||
|
return cancel()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
formData.set('serverUrl', new URL(serverUrl.toString()).origin)
|
||||||
|
} catch {
|
||||||
|
$newestAlert = ['caution', 'Server URL is invalid']
|
||||||
|
return cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = getDeviceUUID()
|
||||||
|
formData.append('deviceId', deviceId)
|
||||||
|
|
||||||
|
return ({ result }) => {
|
||||||
|
if (result.type === 'failure') {
|
||||||
|
return ($newestAlert = ['warning', result.data?.message])
|
||||||
|
} else if (result.type === 'success') {
|
||||||
|
const newConnection: ConnectionInfo = result.data!.newConnection
|
||||||
|
connections = [...connections, newConnection]
|
||||||
|
|
||||||
|
newConnectionModal = null
|
||||||
|
return ($newestAlert = ['success', `Added Jellyfin`])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticateYouTube: SubmitFunction = async ({ formData, cancel }) => {
|
||||||
|
const googleLoginProcess = (): 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await googleLoginProcess()
|
||||||
|
if (!code) cancel()
|
||||||
|
formData.append('code', code)
|
||||||
|
|
||||||
|
return ({ result }) => {
|
||||||
|
if (result.type === 'failure') {
|
||||||
|
return ($newestAlert = ['warning', result.data?.message])
|
||||||
|
} else if (result.type === 'success') {
|
||||||
|
const newConnection: ConnectionInfo = result.data!.newConnection
|
||||||
|
connections = [...connections, newConnection]
|
||||||
|
return ($newestAlert = ['success', 'Added Youtube Music'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileActions: SubmitFunction = ({ action, cancel }) => {
|
||||||
|
return ({ result }) => {
|
||||||
|
if (result.type === 'failure') {
|
||||||
|
return ($newestAlert = ['warning', result.data?.message])
|
||||||
|
} else if (result.type === 'success') {
|
||||||
|
const id = result.data!.deletedConnectionId
|
||||||
|
const indexToDelete = connections.findIndex((connection) => connection.id === id)
|
||||||
|
const serviceType = connections[indexToDelete].type
|
||||||
|
|
||||||
|
connections.splice(indexToDelete, 1)
|
||||||
|
connections = connections
|
||||||
|
|
||||||
|
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newConnectionModal: ComponentType<SvelteComponent<{ submitFunction: SubmitFunction }>> | null = null
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="flex flex-col gap-8">
|
<main class="flex flex-col gap-8">
|
||||||
@@ -27,6 +120,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div>This is where things like history would go</div>
|
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
|
||||||
|
<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={JellyfinIcon} alt="Jellyfin 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={YouTubeMusicIcon} alt="YouTube Music icon" class="aspect-square h-full p-2" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="connection-profile-grid" class="grid gap-8">
|
||||||
|
{#each connections as connection}
|
||||||
|
<ConnectionProfile {connection} submitFunction={profileActions} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if newConnectionModal !== null}
|
||||||
|
<svelte:component this={newConnectionModal} submitFunction={authenticateJellyfin} on:close={() => (newConnectionModal = null)} />
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.add-connection-button {
|
||||||
|
background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));
|
||||||
|
}
|
||||||
|
#connection-profile-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(24rem, 1fr));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
19
src/routes/api/search/+server.ts
Normal file
19
src/routes/api/search/+server.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
|
import { Connections } from '$lib/server/connections'
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const query = url.searchParams.get('query')
|
||||||
|
if (!query) return new Response('Missing query parameter', { status: 400 })
|
||||||
|
const userId = url.searchParams.get('userId')
|
||||||
|
if (!userId) return new Response('Missing userId parameter', { status: 400 })
|
||||||
|
|
||||||
|
const searchResults: (Song | Album | Artist | Playlist)[] = []
|
||||||
|
for (const connection of Connections.getUserConnections(userId)) {
|
||||||
|
await connection
|
||||||
|
.search(query)
|
||||||
|
.then((results) => searchResults.push(...results))
|
||||||
|
.catch((reason) => console.log(`Failed to search "${query}" from connection ${connection.id}: ${reason}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ searchResults })
|
||||||
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Services from '$lib/services.json'
|
|
||||||
import JellyfinIcon from '$lib/static/jellyfin-icon.svg'
|
|
||||||
import YouTubeMusicIcon from '$lib/static/youtube-music-icon.svg'
|
|
||||||
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
|
|
||||||
import { newestAlert } from '$lib/stores.js'
|
|
||||||
import type { PageServerData } from './$types.js'
|
|
||||||
import type { SubmitFunction } from '@sveltejs/kit'
|
|
||||||
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: ConnectionInfo[] = data.connections
|
|
||||||
|
|
||||||
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
|
|
||||||
const { serverUrl, username, password } = Object.fromEntries(formData)
|
|
||||||
|
|
||||||
if (!(serverUrl && username && password)) {
|
|
||||||
$newestAlert = ['caution', 'All fields must be filled out']
|
|
||||||
return cancel()
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
formData.set('serverUrl', new URL(serverUrl.toString()).origin)
|
|
||||||
} catch {
|
|
||||||
$newestAlert = ['caution', 'Server URL is invalid']
|
|
||||||
return cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceId = getDeviceUUID()
|
|
||||||
formData.append('deviceId', deviceId)
|
|
||||||
|
|
||||||
return ({ result }) => {
|
|
||||||
if (result.type === 'failure') {
|
|
||||||
return ($newestAlert = ['warning', result.data?.message])
|
|
||||||
} else if (result.type === 'success') {
|
|
||||||
const newConnection: ConnectionInfo = result.data!.newConnection
|
|
||||||
connections = [...connections, newConnection]
|
|
||||||
|
|
||||||
newConnectionModal = null
|
|
||||||
return ($newestAlert = ['success', `Added Jellyfin`])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const authenticateYouTube: SubmitFunction = async ({ formData, cancel }) => {
|
|
||||||
const googleLoginProcess = (): 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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = await googleLoginProcess()
|
|
||||||
if (!code) cancel()
|
|
||||||
formData.append('code', code)
|
|
||||||
|
|
||||||
return ({ result }) => {
|
|
||||||
if (result.type === 'failure') {
|
|
||||||
return ($newestAlert = ['warning', result.data?.message])
|
|
||||||
} else if (result.type === 'success') {
|
|
||||||
const newConnection: ConnectionInfo = result.data!.newConnection
|
|
||||||
connections = [...connections, newConnection]
|
|
||||||
return ($newestAlert = ['success', 'Added Youtube Music'])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileActions: SubmitFunction = ({ action, cancel }) => {
|
|
||||||
return ({ result }) => {
|
|
||||||
if (result.type === 'failure') {
|
|
||||||
return ($newestAlert = ['warning', result.data?.message])
|
|
||||||
} else if (result.type === 'success') {
|
|
||||||
const id = result.data!.deletedConnectionId
|
|
||||||
const indexToDelete = connections.findIndex((connection) => connection.id === id)
|
|
||||||
const serviceType = connections[indexToDelete].type
|
|
||||||
|
|
||||||
connections.splice(indexToDelete, 1)
|
|
||||||
connections = connections
|
|
||||||
|
|
||||||
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let newConnectionModal: ComponentType<SvelteComponent<{ submitFunction: SubmitFunction }>> | null = null
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
|
|
||||||
<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={JellyfinIcon} alt="Jellyfin 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={YouTubeMusicIcon} alt="YouTube Music icon" class="aspect-square h-full p-2" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<div id="connection-profile-grid" class="grid gap-8">
|
|
||||||
{#each connections as connection}
|
|
||||||
<ConnectionProfile {connection} submitFunction={profileActions} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if newConnectionModal !== null}
|
|
||||||
<svelte:component this={newConnectionModal} submitFunction={authenticateJellyfin} on:close={() => (newConnectionModal = null)} />
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.add-connection-button {
|
|
||||||
background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));
|
|
||||||
}
|
|
||||||
#connection-profile-grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(24rem, 1fr));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user