From 4149cf7528936f6cfb2a5eec8db899ef4e8a03f1 Mon Sep 17 00:00:00 2001 From: Eclypsed Date: Wed, 10 Jan 2024 02:31:49 -0500 Subject: [PATCH] Began work on fetching recommendations; created song factory --- src/lib/components/utility/toggle.svelte | 34 +++--- src/lib/server/db/users.db | Bin 24576 -> 24576 bytes src/lib/utils/utils.js | 102 +++++++++++++----- src/routes/+layout.js | 1 + src/routes/+layout.svelte | 26 ++--- src/routes/+page.server.js | 16 ++- src/routes/+page.svelte | 38 ++++++- src/routes/api/user/connections/+server.js | 1 - .../api/user/recommendations/+server.js | 55 ++++++++++ src/routes/settings/+layout.svelte | 16 +-- .../settings/connections/+page.server.js | 57 +++++----- src/routes/settings/connections/+page.svelte | 4 +- src/routes/songDataSchema.json | 36 +++++++ 13 files changed, 281 insertions(+), 105 deletions(-) create mode 100644 src/routes/+layout.js create mode 100644 src/routes/api/user/recommendations/+server.js create mode 100644 src/routes/songDataSchema.json diff --git a/src/lib/components/utility/toggle.svelte b/src/lib/components/utility/toggle.svelte index 4675682..969c76a 100644 --- a/src/lib/components/utility/toggle.svelte +++ b/src/lib/components/utility/toggle.svelte @@ -4,27 +4,27 @@ import { createEventDispatcher } from 'svelte' const dispatch = createEventDispatcher() - let toggle, knob - const handleToggle = () => { toggled = !toggled - if (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, - }) + dispatch('toggled', { toggled }) } - + + diff --git a/src/lib/server/db/users.db b/src/lib/server/db/users.db index d780bfe7dfcd5a89e63065918e52929e083d283b..5e2c5acbac76dcbdf637b1a574b77a5383fb86b8 100644 GIT binary patch delta 97 zcmZoTz}Rqrae_3X$V3@uMv;vPQvS+DW=WQ2NhT?}sflKZx+bP6rn(lEmgc(2<_0N= UNoI+rX(^lM`r8Yr5TVcl08(KaivR!s delta 97 zcmZoTz}Rqrae_3Xz(g5mMuCk9QvS**2Bt}dY350~Nk)bix+cbHDY_OGNrt*cDTawg U$*Jb4hRK`f`r8Yr5TVcl084KhO8@`> diff --git a/src/lib/utils/utils.js b/src/lib/utils/utils.js index 0acb7d1..c2f7082 100644 --- a/src/lib/utils/utils.js +++ b/src/lib/utils/utils.js @@ -1,3 +1,5 @@ +import Joi from 'joi' + export const ticksToTime = (ticks) => { const totalSeconds = ~~(ticks / 10000000) const totalMinutes = ~~(totalSeconds / 60) @@ -18,46 +20,88 @@ export const ticksToTime = (ticks) => { } export class JellyfinUtils { - static #ROOT_URL = 'http://eclypsecloud:8096/' - static #API_KEY = 'fd4bf4c18e5f4bb08c2cb9f6a1542118' - static #USER_ID = '7364ce5928c64b90b5765e56ca884053' static #AUDIO_PRESETS = { default: { - MaxStreamingBitrate: '999999999', + MaxStreamingBitrate: 999999999, Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg', TranscodingContainer: 'ts', TranscodingProtocol: 'hls', AudioCodec: 'aac', - userId: this.#USER_ID, + // userId: REMEMBER TO ADD THIS TO THE END, }, } - static #buildUrl(baseURL, queryParams) { - const queryParamList = queryParams ? Object.entries(queryParams).map(([key, value]) => `${key}=${value}`) : [] - queryParamList.push(`api_key=${this.#API_KEY}`) - return baseURL.concat('?' + queryParamList.join('&')) + static mediaItemFactory = (itemData, connectionData) => { + const generalItemSchema = Joi.object({ + ServerId: Joi.string().required(), + 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) { - const baseUrl = this.#ROOT_URL + `Users/${this.#USER_ID}/Items` - const endpoint = this.#buildUrl(baseUrl, itemParams) - return endpoint + static songFactory = (songData, connectionData) => { + const { id, serviceType, serviceUserId, serviceUrl } = connectionData + + 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) { - 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() { + static getLocalDeviceUUID = () => { const existingUUID = localStorage.getItem('lazuliDeviceUUID') if (!existingUUID) { @@ -69,3 +113,7 @@ export class JellyfinUtils { return existingUUID } } + +export class YouTubeMusicUtils { + static mediaItemFactory = () => {} +} diff --git a/src/routes/+layout.js b/src/routes/+layout.js new file mode 100644 index 0000000..ed9abb9 --- /dev/null +++ b/src/routes/+layout.js @@ -0,0 +1 @@ +export const trailingSlash = 'never' diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 92dfdbb..acdda16 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -25,24 +25,18 @@ {:else}
- {#if $page.url.pathname === '/login'} -
- -
- {:else} -
- -
- {#if loaded} - - - {/if} -
+ {#if $page.url.pathname !== '/login'} -
- -
{/if} +
+ +
+ {#if loaded} + + + {/if} +
+
{/if} diff --git a/src/routes/+page.server.js b/src/routes/+page.server.js index be91102..6ca80a8 100644 --- a/src/routes/+page.server.js +++ b/src/routes/+page.server.js @@ -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, } } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a84c2a0..9d1e10e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,41 @@ + +{#if !data.recommendations && data.fetchingErrors.length === 0} +
+

Let's Add Some Connections

+

Click the menu in the top left corner and go to Settings > Connections to link to your accounts

+
+{:else} +
+
+ {#each data.recommendations as recommendation} +
+ + {recommendation.name} art +
+
{recommendation.name}
+
{Array.from(recommendation.artists, (artist) => artist.name).join(', ')}
+
+
+ {/each} +
+
+{/if} diff --git a/src/routes/api/user/connections/+server.js b/src/routes/api/user/connections/+server.js index 35882a2..59a75f1 100644 --- a/src/routes/api/user/connections/+server.js +++ b/src/routes/api/user/connections/+server.js @@ -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({ diff --git a/src/routes/api/user/recommendations/+server.js b/src/routes/api/user/recommendations/+server.js new file mode 100644 index 0000000..d83e70d --- /dev/null +++ b/src/routes/api/user/recommendations/+server.js @@ -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 })) +} diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte index 6274727..06d90cb 100644 --- a/src/routes/settings/+layout.svelte +++ b/src/routes/settings/+layout.svelte @@ -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', + // }, } -
+