diff --git a/src/app.d.ts b/src/app.d.ts index b5faf16..823424a 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -41,9 +41,10 @@ declare global { ) interface Connection { + public id: string getRecommendations: () => Promise<(Song | Album | Playlist)[]> getConnectionInfo: () => Promise - 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 = { diff --git a/src/lib/components/util/searchBar.svelte b/src/lib/components/util/searchBar.svelte new file mode 100644 index 0000000..3c7ad35 --- /dev/null +++ b/src/lib/components/util/searchBar.svelte @@ -0,0 +1,31 @@ + + + + + { + if (event.key === 'Enter') triggerSearch(searchInput.value) + }} + /> + diff --git a/src/lib/server/jellyfin.ts b/src/lib/server/jellyfin.ts index 970e876..6d8d47b 100644 --- a/src/lib/server/jellyfin.ts +++ b/src/lib/server/jellyfin.ts @@ -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 => { const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString() return fetch(authUrl, { diff --git a/src/lib/server/youtube-music.ts b/src/lib/server/youtube-music.ts index 72cd97a..25d7ab8 100644 --- a/src/lib/server/youtube-music.ts +++ b/src/lib/server/youtube-music.ts @@ -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 => { diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 40039fd..20c3217 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,5 +1,6 @@ {#if $pageWidth >= 768} -
-
+
+
{#each data.navTabs as nav} @@ -38,7 +39,10 @@
Playlist • {data.user.username}
-
+
+
+ +
diff --git a/src/routes/(app)/search/+page.server.ts b/src/routes/(app)/search/+page.server.ts index 5f26530..88c1669 100644 --- a/src/routes/(app)/search/+page.server.ts +++ b/src/routes/(app)/search/+page.server.ts @@ -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 + } +} diff --git a/src/routes/(app)/search/+page.svelte b/src/routes/(app)/search/+page.svelte index a16595c..d307d8a 100644 --- a/src/routes/(app)/search/+page.svelte +++ b/src/routes/(app)/search/+page.svelte @@ -1 +1,11 @@ -

Search Page

+ + +{#if data.searchResults} + {#each data.searchResults as searchResult} +
{searchResult.name} - {searchResult.type}
+ {/each} +{/if} diff --git a/src/routes/settings/connections/+page.server.ts b/src/routes/(app)/user/+page.server.ts similarity index 100% rename from src/routes/settings/connections/+page.server.ts rename to src/routes/(app)/user/+page.server.ts diff --git a/src/routes/(app)/user/+page.svelte b/src/routes/(app)/user/+page.svelte index c9ec20d..e431141 100644 --- a/src/routes/(app)/user/+page.svelte +++ b/src/routes/(app)/user/+page.svelte @@ -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 => { + 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> | null = null
@@ -27,6 +120,35 @@
-
This is where things like history would go
+
+

Add Connection

+
+ +
+ +
+
+
+
+ {#each connections as connection} + + {/each} +
+ {#if newConnectionModal !== null} + (newConnectionModal = null)} /> + {/if}
+ + diff --git a/src/routes/settings/connections/connectionProfile.svelte b/src/routes/(app)/user/connectionProfile.svelte similarity index 100% rename from src/routes/settings/connections/connectionProfile.svelte rename to src/routes/(app)/user/connectionProfile.svelte diff --git a/src/routes/settings/connections/jellyfinAuthBox.svelte b/src/routes/(app)/user/jellyfinAuthBox.svelte similarity index 100% rename from src/routes/settings/connections/jellyfinAuthBox.svelte rename to src/routes/(app)/user/jellyfinAuthBox.svelte diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts new file mode 100644 index 0000000..747c8af --- /dev/null +++ b/src/routes/api/search/+server.ts @@ -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 }) +} diff --git a/src/routes/settings/connections/+page.svelte b/src/routes/settings/connections/+page.svelte deleted file mode 100644 index fa00ace..0000000 --- a/src/routes/settings/connections/+page.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - -
-
-

Add Connection

-
- -
- -
-
-
-
- {#each connections as connection} - - {/each} -
- {#if newConnectionModal !== null} - (newConnectionModal = null)} /> - {/if} -
- -