Began work on fetching recommendations; created song factory
This commit is contained in:
@@ -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.
@@ -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
1
src/routes/+layout.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const trailingSlash = 'never'
|
||||||
@@ -25,11 +25,9 @@
|
|||||||
<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">
|
<Navbar />
|
||||||
<slot />
|
{/if}
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="fixed isolate -z-10 h-full w-full bg-black">
|
<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!) -->
|
<!-- 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" />
|
<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 }} />
|
<img id="background-image" src={backgroundImage} alt="" class="h-1/2 w-full object-cover blur-xl" in:fade={{ duration: 1000 }} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Navbar />
|
|
||||||
<div class="h-full pt-16">
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<AlertBox bind:this={alertBox} />
|
<AlertBox bind:this={alertBox} />
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 > 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 })
|
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({
|
||||||
|
|||||||
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',
|
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>
|
||||||
|
|||||||
@@ -2,23 +2,15 @@ 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) => {
|
|
||||||
const connectionData = UserConnections.getConnection(connectionId)
|
|
||||||
const { serviceType, serviceUserId, serviceUrl, accessToken } = connectionData
|
|
||||||
|
|
||||||
switch (serviceType) {
|
switch (serviceType) {
|
||||||
case 'jellyfin':
|
case 'jellyfin':
|
||||||
const userUrl = new URL(`Users/${serviceUserId}`, serviceUrl).href
|
const userUrl = new URL(`Users/${serviceUserId}`, serviceUrl).href
|
||||||
const systemUrl = new URL('System/Info', serviceUrl).href
|
const systemUrl = new URL('System/Info', serviceUrl).href
|
||||||
|
|
||||||
const reqHeaders = new Headers()
|
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
|
||||||
reqHeaders.append('Authorization', `MediaBrowser Token="${accessToken}"`)
|
|
||||||
|
|
||||||
const userResponse = await fetch(userUrl, { headers: reqHeaders })
|
const userResponse = await fetch(userUrl, { headers: reqHeaders })
|
||||||
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
|
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
|
||||||
@@ -27,6 +19,7 @@ class ConnectionProfile {
|
|||||||
const systemData = await systemResponse.json()
|
const systemData = await systemResponse.json()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
connectionId: id,
|
||||||
serviceType,
|
serviceType,
|
||||||
userId: serviceUserId,
|
userId: serviceUserId,
|
||||||
username: userData?.Name,
|
username: userData?.Name,
|
||||||
@@ -36,7 +29,6 @@ class ConnectionProfile {
|
|||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('./$types').PageServerLoad} */
|
/** @type {import('./$types').PageServerLoad} */
|
||||||
@@ -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 }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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