Began work on fetching recommendations; created song factory
This commit is contained in:
1
src/routes/+layout.js
Normal file
1
src/routes/+layout.js
Normal file
@@ -0,0 +1 @@
|
||||
export const trailingSlash = 'never'
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 > 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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
55
src/routes/api/user/recommendations/+server.js
Normal file
55
src/routes/api/user/recommendations/+server.js
Normal 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 }))
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
36
src/routes/songDataSchema.json
Normal file
36
src/routes/songDataSchema.json
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user