Moved connections to user page, began search functionality

This commit is contained in:
Eclypsed
2024-03-30 01:15:12 -04:00
parent a4bad9d73b
commit a624f375e4
13 changed files with 242 additions and 165 deletions

8
src/app.d.ts vendored
View File

@@ -41,9 +41,10 @@ declare global {
)
interface Connection {
public id: string
getRecommendations: () => Promise<(Song | Album | Playlist)[]>
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.
// 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
}
// IMPORTANT: This interface is for Lazuli created and stored playlists. Use service-specific interfaces when pulling playlists from services
type Playlist = {
id: string
name: string
type: 'playlist'
thumbnail?: string
description?: string
items: {
connectionId: string
id: string
}[]
}
type Artist = {

View 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>

View File

@@ -1,5 +1,5 @@
export class Jellyfin implements Connection {
private id: string
public id: string
private userId: string
private jfUserId: string
private serverUrl: string
@@ -72,21 +72,23 @@ export class Jellyfin implements Connection {
public search = async (searchTerm: string): Promise<(Song | Album | Playlist)[]> => {
const searchParams = new URLSearchParams({
searchTerm,
includeItemTypes: 'Audio,MusicAlbum', // Potentially add MusicArtist
includeItemTypes: 'Audio,MusicAlbum,Playlist', // Potentially add MusicArtist
recursive: 'true',
})
const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl).toString()
const searchResponse = await fetch(searchURL, { headers: this.BASEHEADERS })
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) {
case 'Audio':
return this.parseSong(result)
case 'MusicAlbum':
return this.parseAlbum(result)
case 'Playlist':
return this.parsePlaylist(result)
}
})
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> => {
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString()
return fetch(authUrl, {

View File

@@ -4,7 +4,7 @@ import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
export class YouTubeMusic implements Connection {
private id: string
public id: string
private userId: string
private ytUserId: string
private tokens: YouTubeMusic.Tokens
@@ -77,23 +77,25 @@ export class YouTubeMusic implements Connection {
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 response = await fetch(`https://music.youtube.com/youtubei/v1/search`, {
headers,
method: 'POST',
body: JSON.stringify({
query: searchTerm,
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${formatDate()}.01.00`,
hl: 'en',
},
},
}),
})
// const response = await fetch(`https://music.youtube.com/youtubei/v1/search`, {
// headers,
// method: 'POST',
// body: JSON.stringify({
// query: searchTerm,
// context: {
// client: {
// clientName: 'WEB_REMIX',
// clientVersion: `1.${formatDate()}.01.00`,
// hl: 'en',
// },
// },
// }),
// })
const data = await response.json()
console.log(JSON.stringify(data))
// const data = await response.json()
// console.log(JSON.stringify(data))
return []
}
private getHome = async (): Promise<YouTubeMusic.HomeItems> => {

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { pageWidth } from '$lib/stores'
import SearchBar from '$lib/components/util/searchBar.svelte'
import type { LayoutData } from './$types'
import NavTab from '$lib/components/navbar/navTab.svelte'
import PlaylistTab from '$lib/components/navbar/playlistTab.svelte'
@@ -21,8 +22,8 @@
</script>
{#if $pageWidth >= 768}
<div class="grid h-full grid-rows-1 gap-8 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="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-12">
<div class="flex flex-col gap-4">
{#each data.navTabs as nav}
<NavTab {nav} disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)} />
@@ -38,7 +39,10 @@
<div class="overflow-clip text-ellipsis text-neutral-400">Playlist &bull; {data.user.username}</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 />
</section>
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">

View File

@@ -1,4 +1,14 @@
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
}
}

View File

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

View File

@@ -2,8 +2,101 @@
import IconButton from '$lib/components/util/iconButton.svelte'
import { goto } from '$app/navigation'
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>
<main class="flex flex-col gap-8">
@@ -27,6 +120,35 @@
</div>
</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>
</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>

View 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 })
}

View File

@@ -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>