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

View File

@@ -4,27 +4,27 @@
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let toggle, knob
const handleToggle = () => { const handleToggle = () => {
toggled = !toggled toggled = !toggled
if (toggled) { dispatch('toggled', { toggled })
toggle.style.backgroundColor = 'var(--lazuli-primary)'
knob.style.left = '100%'
knob.style.transform = 'translateX(-100%)'
} else {
toggle.style.backgroundColor = 'rgb(115, 115, 115)'
knob.style.left = 0
knob.style.transform = ''
}
dispatch('toggled', {
toggleState: toggled,
})
} }
</script> </script>
<button bind:this={toggle} aria-checked={toggle} role="checkbox" class="relative flex h-6 w-10 items-center rounded-full bg-neutral-500 transition-colors" on:click={handleToggle}> <button class:toggled aria-checked={toggled} role="checkbox" class="relative flex h-6 w-10 items-center rounded-full bg-neutral-500 transition-colors" on:click={handleToggle}>
<div bind:this={knob} class="absolute left-0 aspect-square h-full p-1 transition-all"> <div class:toggled class="absolute left-0 aspect-square h-full p-1 transition-all">
<div class="h-full w-full rounded-full bg-white"></div> <div class="grid h-full w-full place-items-center rounded-full bg-white">
<i class={toggled ? 'fa-solid fa-check text-xs' : 'fa-solid fa-xmark text-xs'} />
</div>
</div> </div>
</button> </button>
<style>
button.toggled {
background-color: var(--lazuli-primary);
}
div.toggled {
left: 100%;
transform: translateX(-100%);
color: var(--lazuli-primary);
}
</style>

Binary file not shown.

View File

@@ -1,3 +1,5 @@
import Joi from 'joi'
export const ticksToTime = (ticks) => { export const ticksToTime = (ticks) => {
const totalSeconds = ~~(ticks / 10000000) const totalSeconds = ~~(ticks / 10000000)
const totalMinutes = ~~(totalSeconds / 60) const totalMinutes = ~~(totalSeconds / 60)
@@ -18,46 +20,88 @@ export const ticksToTime = (ticks) => {
} }
export class JellyfinUtils { export class JellyfinUtils {
static #ROOT_URL = 'http://eclypsecloud:8096/'
static #API_KEY = 'fd4bf4c18e5f4bb08c2cb9f6a1542118'
static #USER_ID = '7364ce5928c64b90b5765e56ca884053'
static #AUDIO_PRESETS = { static #AUDIO_PRESETS = {
default: { default: {
MaxStreamingBitrate: '999999999', MaxStreamingBitrate: 999999999,
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg', Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg',
TranscodingContainer: 'ts', TranscodingContainer: 'ts',
TranscodingProtocol: 'hls', TranscodingProtocol: 'hls',
AudioCodec: 'aac', AudioCodec: 'aac',
userId: this.#USER_ID, // userId: REMEMBER TO ADD THIS TO THE END,
}, },
} }
static #buildUrl(baseURL, queryParams) { static mediaItemFactory = (itemData, connectionData) => {
const queryParamList = queryParams ? Object.entries(queryParams).map(([key, value]) => `${key}=${value}`) : [] const generalItemSchema = Joi.object({
queryParamList.push(`api_key=${this.#API_KEY}`) ServerId: Joi.string().required(),
return baseURL.concat('?' + queryParamList.join('&')) Type: Joi.string().required(),
}).unknown(true)
const generalItemValidation = generalItemSchema.validate(itemData)
if (generalItemValidation.error) throw new Error(generalItemValidation.error.message)
switch (itemData.Type) {
case 'Audio':
return this.songFactory(itemData, connectionData)
case 'MusicAlbum':
break
}
} }
static getItemsEnpt(itemParams) { static songFactory = (songData, connectionData) => {
const baseUrl = this.#ROOT_URL + `Users/${this.#USER_ID}/Items` const { id, serviceType, serviceUserId, serviceUrl } = connectionData
const endpoint = this.#buildUrl(baseUrl, itemParams)
return endpoint const songSchema = Joi.object({
Name: Joi.string().required(),
Id: Joi.string().required(),
RunTimeTicks: Joi.number().required(),
}).unknown(true)
const songValidation = songSchema.validate(songData)
if (songValidation.error) throw new Error(songValidation.error.message)
const artistData = songData?.ArtistItems
? Array.from(songData.ArtistItems, (artist) => {
return { name: artist.Name, id: artist.Id }
})
: null
const albumData = songData?.AlbumId
? {
name: songData.Album,
id: songData.AlbumId,
artists: songData.AlbumArtists,
image: songData?.AlbumPrimaryImageTag ? new URL(`Items/${songData.AlbumId}/Images/Primary`, serviceUrl).href : null,
}
: null
const imageSource = songData?.ImageTags?.Primary
? new URL(`Items/${songData.Id}/Images/Primary`, serviceUrl).href
: songData?.AlbumPrimaryImageTag
? new URL(`Items/${songData.AlbumId}/Images/Primary`, serviceUrl).href
: null
const audioSearchParams = new URLSearchParams(this.#AUDIO_PRESETS.default)
audioSearchParams.append('userId', serviceUserId)
const audoSource = new URL(`Audio/${songData.Id}/universal?${audioSearchParams.toString()}`, serviceUrl).href
return {
connectionId: id,
serviceType,
mediaType: 'song',
name: songData.Name,
id: songData.Id,
duration: Math.floor(songData.RunTimeTicks / 10000), // <-- Converts 'ticks' (whatever that means) to milliseconds, a sane unit of measure
artists: artistData,
album: albumData,
image: imageSource,
audio: audoSource,
video: null,
releaseDate: songData?.ProductionYear,
}
} }
static getImageEnpt(id, imageParams) { static getLocalDeviceUUID = () => {
const baseUrl = this.#ROOT_URL + `Items/${id}/Images/Primary`
const endpoint = this.#buildUrl(baseUrl, imageParams)
return endpoint
}
static getAudioEnpt(id, audioPreset) {
const baseUrl = this.#ROOT_URL + `Audio/${id}/universal`
const presetParams = this.#AUDIO_PRESETS[audioPreset]
const endpoint = this.#buildUrl(baseUrl, presetParams)
return endpoint
}
static getLocalDeviceUUID() {
const existingUUID = localStorage.getItem('lazuliDeviceUUID') const existingUUID = localStorage.getItem('lazuliDeviceUUID')
if (!existingUUID) { if (!existingUUID) {
@@ -69,3 +113,7 @@ export class JellyfinUtils {
return existingUUID return existingUUID
} }
} }
export class YouTubeMusicUtils {
static mediaItemFactory = () => {}
}

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

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

View File

@@ -25,24 +25,18 @@
<slot /> <slot />
{:else} {:else}
<main class="h-screen font-notoSans text-white"> <main class="h-screen font-notoSans text-white">
{#if $page.url.pathname === '/login'} {#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>
<Navbar /> <Navbar />
<div class="h-full pt-16">
<slot />
</div>
{/if} {/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} /> <AlertBox bind:this={alertBox} />
</main> </main>
{/if} {/if}

View File

@@ -1,6 +1,20 @@
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
/** @type {import('./$types').PageServerLoad} */ /** @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 { return {
user: locals.user, user: locals.user,
recommendations,
fetchingErrors: errors,
} }
} }

View File

@@ -1,5 +1,41 @@
<script> <script>
import { onMount } from 'svelte'
import { newestAlert } from '$lib/stores/alertStore.js'
export let data 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> </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 }) 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} */ /** @type {import('./$types').RequestHandler} */
export async function PATCH({ request, url }) { export async function PATCH({ request, url }) {
const schema = Joi.object({ 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', uri: '/settings/connections',
icon: 'fa-solid fa-circle-nodes', icon: 'fa-solid fa-circle-nodes',
}, },
devices: { // devices: {
displayName: 'Devices', // displayName: 'Devices',
uri: '/settings/devices', // uri: '/settings/devices',
icon: 'fa-solid fa-mobile-screen', // icon: 'fa-solid fa-mobile-screen',
}, // },
} }
</script> </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"> <nav class="h-full rounded-lg p-6">
<h1 class="flex h-6 justify-between text-neutral-400"> <h1 class="flex h-6 justify-between text-neutral-400">
<span> <span>
<i class="fa-solid fa-gear" /> <i class="fa-solid fa-gear" />
Settings Settings
</span> </span>
{#if $page.url.pathname.replaceAll('/', ' ').trim().split(' ').at(-1) !== 'settings'} {#if $page.url.pathname.split('/').at(-1) !== 'settings'}
<IconButton on:click={() => goto('/settings')}> <IconButton on:click={() => goto('/settings')}>
<i slot="icon" class="fa-solid fa-caret-left" /> <i slot="icon" class="fa-solid fa-caret-left" />
</IconButton> </IconButton>
@@ -39,7 +39,7 @@
{route.displayName} {route.displayName}
</div> </div>
{:else} {: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} /> <i class={route.icon} />
{route.displayName} {route.displayName}
</a> </a>

View File

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

View File

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