Began work on fetching recommendations; created song factory
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
</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}>
|
||||
<div bind:this={knob} class="absolute left-0 aspect-square h-full p-1 transition-all">
|
||||
<div class="h-full w-full rounded-full bg-white"></div>
|
||||
<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 class:toggled class="absolute left-0 aspect-square h-full p-1 transition-all">
|
||||
<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>
|
||||
</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.
@@ -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 = () => {}
|
||||
}
|
||||
|
||||
1
src/routes/+layout.js
Normal file
1
src/routes/+layout.js
Normal file
@@ -0,0 +1 @@
|
||||
export const trailingSlash = 'never'
|
||||
@@ -25,11 +25,9 @@
|
||||
<slot />
|
||||
{:else}
|
||||
<main class="h-screen font-notoSans text-white">
|
||||
{#if $page.url.pathname === '/login'}
|
||||
<div class="bg-black h-full">
|
||||
<slot />
|
||||
</div>
|
||||
{:else}
|
||||
{#if $page.url.pathname !== '/login'}
|
||||
<Navbar />
|
||||
{/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" />
|
||||
@@ -38,11 +36,7 @@
|
||||
<img id="background-image" src={backgroundImage} alt="" class="h-1/2 w-full object-cover blur-xl" in:fade={{ duration: 1000 }} />
|
||||
{/if}
|
||||
</div>
|
||||
<Navbar />
|
||||
<div class="h-full pt-16">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
<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,23 +2,15 @@ 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 }
|
||||
}
|
||||
|
||||
static getUserData = async (connectionId) => {
|
||||
const connectionData = UserConnections.getConnection(connectionId)
|
||||
const { serviceType, serviceUserId, serviceUrl, accessToken } = connectionData
|
||||
const createProfile = async (connectionData) => {
|
||||
const { id, serviceType, serviceUserId, serviceUrl, accessToken, refreshToken, expiry } = connectionData
|
||||
|
||||
switch (serviceType) {
|
||||
case 'jellyfin':
|
||||
const userUrl = new URL(`Users/${serviceUserId}`, serviceUrl).href
|
||||
const systemUrl = new URL('System/Info', serviceUrl).href
|
||||
|
||||
const reqHeaders = new Headers()
|
||||
reqHeaders.append('Authorization', `MediaBrowser Token="${accessToken}"`)
|
||||
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
|
||||
|
||||
const userResponse = await fetch(userUrl, { headers: reqHeaders })
|
||||
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
|
||||
@@ -27,6 +19,7 @@ class ConnectionProfile {
|
||||
const systemData = await systemResponse.json()
|
||||
|
||||
return {
|
||||
connectionId: id,
|
||||
serviceType,
|
||||
userId: serviceUserId,
|
||||
username: userData?.Name,
|
||||
@@ -37,7 +30,6 @@ class ConnectionProfile {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('./$types').PageServerLoad} */
|
||||
export const load = async ({ fetch, locals }) => {
|
||||
@@ -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