Began work on fetching recommendations; created song factory

This commit is contained in:
Eclypsed
2024-01-10 02:31:49 -05:00
parent f9359ae300
commit 4149cf7528
13 changed files with 281 additions and 105 deletions

1
src/routes/+layout.js Normal file
View File

@@ -0,0 +1 @@
export const trailingSlash = 'never'

View File

@@ -25,24 +25,18 @@
<slot />
{:else}
<main class="h-screen font-notoSans text-white">
{#if $page.url.pathname === '/login'}
<div class="bg-black h-full">
<slot />
</div>
{:else}
<div class="fixed isolate -z-10 h-full w-full bg-black">
<!-- This whole bg is a complete copy of ytmusic, design own at some point (Place for customization w/ album art etc?) (EDIT: Ok, it looks SICK with album art!) -->
<div id="background-gradient" class="absolute z-10 h-1/2 w-full bg-cover" />
{#if loaded}
<!-- May want to add a small blur filter in the event that the album/song image is below a certain resolution -->
<img id="background-image" src={backgroundImage} alt="" class="h-1/2 w-full object-cover blur-xl" in:fade={{ duration: 1000 }} />
{/if}
</div>
{#if $page.url.pathname !== '/login'}
<Navbar />
<div class="h-full pt-16">
<slot />
</div>
{/if}
<div class="fixed isolate -z-10 h-full w-full bg-black">
<!-- This whole bg is a complete copy of ytmusic, design own at some point (Place for customization w/ album art etc?) (EDIT: Ok, it looks SICK with album art!) -->
<div id="background-gradient" class="absolute z-10 h-1/2 w-full bg-cover" />
{#if loaded}
<!-- May want to add a small blur filter in the event that the album/song image is below a certain resolution -->
<img id="background-image" src={backgroundImage} alt="" class="h-1/2 w-full object-cover blur-xl" in:fade={{ duration: 1000 }} />
{/if}
</div>
<slot />
<AlertBox bind:this={alertBox} />
</main>
{/if}

View File

@@ -1,6 +1,20 @@
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
/** @type {import('./$types').PageServerLoad} */
export const load = ({ locals }) => {
export const load = async ({ locals, fetch }) => {
const recommendationResponse = await fetch(`/api/user/recommendations?userId=${locals.userId}&limit=10`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
})
const recommendationsData = await recommendationResponse.json()
const { recommendations, errors } = recommendationsData
console.log(recommendations[0].artists)
return {
user: locals.user,
recommendations,
fetchingErrors: errors,
}
}

View File

@@ -1,5 +1,41 @@
<script>
import { onMount } from 'svelte'
import { newestAlert } from '$lib/stores/alertStore.js'
export let data
const connectionsData = data.connections
onMount(() => {
const logFetchError = (index, errors) => {
if (index >= errors.length) return
const errorMessage = errors[index]
$newestAlert = ['warning', errorMessage]
setTimeout(() => logFetchError((index += 1), errors), 100)
}
logFetchError(0, data.fetchingErrors)
})
</script>
{#if !data.recommendations && data.fetchingErrors.length === 0}
<main class="flex h-full flex-col items-center justify-center gap-4 text-center">
<h1 class="text-4xl">Let's Add Some Connections</h1>
<p class="text-neutral-400">Click the menu in the top left corner and go to Settings &gt; Connections to link to your accounts</p>
</main>
{:else}
<main id="recommendations-wrapper" class="p-12 pt-24">
<section class="no-scrollbar flex gap-6 overflow-scroll">
{#each data.recommendations as recommendation}
<div class="aspect-[4/5] w-56 flex-shrink-0">
<!-- Add placeholder image for when recommendation.image is null -->
<img class="aspect-square w-full rounded-md object-cover" src="{recommendation.image}?width=224&height=224" alt="{recommendation.name} art" />
<div class="mt-3 px-1 text-sm">
<div>{recommendation.name}</div>
<div class="text-neutral-400">{Array.from(recommendation.artists, (artist) => artist.name).join(', ')}</div>
</div>
</div>
{/each}
</section>
</main>
{/if}

View File

@@ -20,7 +20,6 @@ export async function GET({ url }) {
return new Response(JSON.stringify(userConnections), { headers: responseHeaders })
}
// May need to add support for refresh token and expiry in the future
/** @type {import('./$types').RequestHandler} */
export async function PATCH({ request, url }) {
const schema = Joi.object({

View File

@@ -0,0 +1,55 @@
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import { JellyfinUtils } from '$lib/utils/utils'
import Joi from 'joi'
/** @type {import('./$types').RequestHandler} */
export async function GET({ url, fetch }) {
const { userId, limit } = Object.fromEntries(url.searchParams)
if (!(userId && limit)) return new Response('userId and limit parameter required', { status: 400 })
const connectionsResponse = await fetch(`/api/user/connections?userId=${userId}`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
})
const allConnections = await connectionsResponse.json()
const recommendations = []
const errors = []
for (const connectionData of allConnections) {
const { id, serviceType, serviceUserId, serviceUrl, accessToken } = connectionData
switch (serviceType) {
case 'jellyfin':
const mostPlayedSongsSearchParams = new URLSearchParams({
SortBy: 'PlayCount',
SortOrder: 'Descending',
IncludeItemTypes: 'Audio',
Recursive: true,
limit,
})
const mostPlayedSongsUrl = new URL(`Users/${serviceUserId}/Items?${mostPlayedSongsSearchParams.toString()}`, serviceUrl).href
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
const mostPlayedResponse = await fetch(mostPlayedSongsUrl, { headers: reqHeaders })
const mostPlayedData = await mostPlayedResponse.json()
const schema = Joi.object({
Items: Joi.array().length(Number(limit)).required(),
}).unknown(true)
const validation = schema.validate(mostPlayedData)
if (validation.error) {
errors.push(validation.error.message)
break
}
mostPlayedData.Items.forEach((song) => recommendations.push(JellyfinUtils.mediaItemFactory(song, connectionData)))
break
}
}
return new Response(JSON.stringify({ recommendations, errors }))
}

View File

@@ -9,22 +9,22 @@
uri: '/settings/connections',
icon: 'fa-solid fa-circle-nodes',
},
devices: {
displayName: 'Devices',
uri: '/settings/devices',
icon: 'fa-solid fa-mobile-screen',
},
// devices: {
// displayName: 'Devices',
// uri: '/settings/devices',
// icon: 'fa-solid fa-mobile-screen',
// },
}
</script>
<main class="mx-auto grid h-full max-w-screen-xl gap-8 p-8">
<main class="mx-auto grid h-full max-w-screen-xl gap-8 p-8 pt-24">
<nav class="h-full rounded-lg p-6">
<h1 class="flex h-6 justify-between text-neutral-400">
<span>
<i class="fa-solid fa-gear" />
Settings
</span>
{#if $page.url.pathname.replaceAll('/', ' ').trim().split(' ').at(-1) !== 'settings'}
{#if $page.url.pathname.split('/').at(-1) !== 'settings'}
<IconButton on:click={() => goto('/settings')}>
<i slot="icon" class="fa-solid fa-caret-left" />
</IconButton>
@@ -39,7 +39,7 @@
{route.displayName}
</div>
{:else}
<a href={route.uri} class="block rounded-lg px-3 py-1 hover:bg-neutral-700">
<a href={route.uri} class="block rounded-lg px-3 py-1 opacity-50 hover:bg-neutral-700">
<i class={route.icon} />
{route.displayName}
</a>

View File

@@ -2,40 +2,32 @@ import { fail } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import { UserConnections } from '$lib/server/db/users'
class ConnectionProfile {
static async createProfile(connectionId) {
const connectionData = await this.getUserData(connectionId)
return { connectionId, ...connectionData }
}
const createProfile = async (connectionData) => {
const { id, serviceType, serviceUserId, serviceUrl, accessToken, refreshToken, expiry } = connectionData
static getUserData = async (connectionId) => {
const connectionData = UserConnections.getConnection(connectionId)
const { serviceType, serviceUserId, serviceUrl, accessToken } = connectionData
switch (serviceType) {
case 'jellyfin':
const userUrl = new URL(`Users/${serviceUserId}`, serviceUrl).href
const systemUrl = new URL('System/Info', serviceUrl).href
switch (serviceType) {
case 'jellyfin':
const userUrl = new URL(`Users/${serviceUserId}`, serviceUrl).href
const systemUrl = new URL('System/Info', serviceUrl).href
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
const reqHeaders = new Headers()
reqHeaders.append('Authorization', `MediaBrowser Token="${accessToken}"`)
const userResponse = await fetch(userUrl, { headers: reqHeaders })
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
const userResponse = await fetch(userUrl, { headers: reqHeaders })
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
const userData = await userResponse.json()
const systemData = await systemResponse.json()
const userData = await userResponse.json()
const systemData = await systemResponse.json()
return {
serviceType,
userId: serviceUserId,
username: userData?.Name,
serviceUrl: serviceUrl,
serverName: systemData?.ServerName,
}
default:
return null
}
return {
connectionId: id,
serviceType,
userId: serviceUserId,
username: userData?.Name,
serviceUrl: serviceUrl,
serverName: systemData?.ServerName,
}
default:
return null
}
}
@@ -51,7 +43,7 @@ export const load = async ({ fetch, locals }) => {
const connectionProfiles = []
if (allConnections) {
for (const connection of allConnections) {
const connectionProfile = await ConnectionProfile.createProfile(connection.id)
const connectionProfile = await createProfile(connection)
connectionProfiles.push(connectionProfile)
}
}
@@ -93,9 +85,10 @@ export const actions = {
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
const newConnectionData = await updateConnectionsResponse.json()
const newConnection = await updateConnectionsResponse.json()
const newConnectionData = UserConnections.getConnection(newConnection.id)
const jellyfinProfile = await ConnectionProfile.createProfile(newConnectionData.id)
const jellyfinProfile = await createProfile(newConnectionData)
return { newConnection: jellyfinProfile }
},

View File

@@ -91,7 +91,7 @@
<div>{connectionProfile?.username ? connectionProfile.username : 'Placeholder Account Name'}</div>
<div class="text-sm text-neutral-500">
{serviceData.displayName}
{#if connectionProfile.serviceType === 'jellyfin'}
{#if connectionProfile.serviceType === 'jellyfin' && connectionProfile?.serverName}
- {connectionProfile.serverName}
{/if}
</div>
@@ -105,7 +105,7 @@
<hr class="mx-2 border-t-2 border-neutral-600" />
<div class="p-4 text-sm text-neutral-400">
<div class="grid grid-cols-[3rem_auto] gap-4">
<Toggle on:toggled={(event) => console.log(event.detail.toggleState)} />
<Toggle on:toggled={(event) => console.log(event.detail.toggled)} />
<span>Enable Connection</span>
</div>
</div>

View File

@@ -0,0 +1,36 @@
{
"connectionId": "Id of the connection that provides the song",
"serviceType": "The type of service that provides the song",
"mediaType": "song",
"name": "Name of song",
"id": "whatever unique identifier the service provides",
"duration": "length of song in milliseconds",
"artists": [
{
"name": "Name of artist",
"id": "service's unique identifier for the artist"
}
],
"album": {
"name": "Name of album",
"id": "service's unique identifier for the album",
"artists": [
{
"name": "Name of artist",
"id": "service's unique identifier for the artist"
}
],
"image": "source url of the album art"
},
"image": "source url of image unique to the song, if one does not exist this will be the album art or in the case of videos the thumbnail",
"audio": "source url of the audio stream",
"video": "source url of the video stream (if this is not null then player will allow for video mode, otherwise use image)",
"releaseDate": "Either the date the MV was upload or the release date/year of the album",
"All of the above data": "Is comprised solely of data that has been read from the respective service and processed by a factory",
"the above data should not contain or rely on any information that has to be read from the lazuli server": "save for the data that was read to fetch it in the first place (connectionId, serviceType, etc.)",
"data that requires something else to be read from the lazuli server, such as presence in a playlist or marked as favorite": "should not be implemented into any visual design and is to be fetched as needed",
"When it comes to determining whether or not a song should be displayed as a video": "This is determined solely by the presence of a video source url, which will automatically be null for all sources besides youtube obviously",
"If a song does have a video source, it will be dispayed as a music video by default": "The presence of this property will likely determine certain styling, as well as the presence of the song/video switcher in the player"
}