Gonna try to start using git properly

This commit is contained in:
Eclypsed
2024-01-06 22:05:51 -05:00
parent b2790a7151
commit 0da467d1e0
57 changed files with 2592 additions and 341 deletions

View File

@@ -12,3 +12,27 @@
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Default scrollbar for Chrome, Safari, Edge and Opera */
::-webkit-scrollbar {
width: 20px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 10px 10px rgba(100, 100, 100, 0.6);
border: solid 7px transparent;
}
/* Default scrollbar for Chrome, Safari, Edge, and Opera */
:root {
scrollbar-width: thin; /* Default scrollbar width for Firefox */
scrollbar-color: grey transparent; /* Default scrollbar colors for Firefox */
--jellyfin-purple: #aa5cc3;
--jellyfin-blue: #00a4dc;
--lazuli-primary: #ed6713;
}

27
src/hooks.server.js Normal file
View File

@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit'
import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY } from '$env/static/private'
import jwt from 'jsonwebtoken'
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
const nonProtectedRoutes = ['/login']
const urlpath = event.url.pathname
if (urlpath.startsWith('/api') && event.request.headers.get('apikey') !== SECRET_INTERNAL_API_KEY) {
return new Response('Unauthorized', { status: 400 })
}
if (!nonProtectedRoutes.some((route) => urlpath.startsWith(route))) {
const authToken = event.cookies.get('lazuli-auth')
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
const tokenData = jwt.verify(authToken, SECRET_JWT_KEY)
if (!tokenData) throw redirect(303, `/login?redirect=${urlpath}`)
event.locals.userId = tokenData.id
event.locals.username = tokenData.user
}
const response = await resolve(event)
return response
}

View File

@@ -1,74 +0,0 @@
export const ROOT_URL = 'http://eclypsecloud:8096/'
export const API_KEY = 'fd4bf4c18e5f4bb08c2cb9f6a1542118'
export const USER_ID = '7364ce5928c64b90b5765e56ca884053'
const baseURLGenerator = {
Items: () => `Users/${USER_ID}/Items`,
Image: (params) => `Items/${params.id}/Images/Primary`,
Audio: (params) => `Audio/${params.id}/universal`,
}
export const generateURL = ({ type, pathParams, queryParams }) => {
const baseURLFunction = baseURLGenerator[type]
if (baseURLFunction) {
const baseURL = ROOT_URL.concat(baseURLFunction(pathParams))
const queryParamList = queryParams ? Object.entries(queryParams).map(([key, value]) => `${key}=${value}`) : []
queryParamList.push(`api_key=${API_KEY}`)
return baseURL.concat('?' + queryParamList.join('&'))
} else {
throw new Error('API Url Type does not exist')
}
}
export const fetchArtistItems = async (artistId) => {
try {
const response = await fetch(
generateURL({
type: 'Items',
queryParams: { artistIds: artistId, recursive: true },
}),
)
const data = await response.json()
const artistItems = {
albums: [],
singles: [],
appearances: [],
}
// Filters the raw list of items to only the albums that were produced in fully or in part by the specified artist
artistItems.albums = data.Items.filter((item) => item.Type === 'MusicAlbum' && item.AlbumArtists.some((artist) => artist.Id === artistId))
data.Items.forEach((item) => {
if (item.Type === 'Audio') {
if (!('AlbumId' in item)) {
artistItems.singles.push(item)
} else if (!artistItems.albums.some((album) => album.Id === item.AlbumId)) {
artistItems.appearances.push(item)
}
}
})
return artistItems
} catch (error) {
console.log('Error Fetching Artist Items:', error)
}
}
export const fetchSong = async (songId) => {
try {
const response = await fetch(
generateURL({
type: 'Items',
queryParams: { ids: songId, recursive: true },
}),
)
const data = await response.json()
return data.Items[0]
} catch (error) {
console.log('Error Fetch Song', error)
}
}

View File

@@ -1,21 +0,0 @@
import { generateURL } from '$lib/Jellyfin-api'
import { USER_ID } from '$lib/Jellyfin-api'
const paramPresets = {
default: {
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: USER_ID,
},
}
export const buildAudioEndpoint = (id, params) => {
return generateURL({
type: 'Audio',
pathParams: { id: id },
queryParams: paramPresets[params],
})
}

View File

@@ -2,7 +2,7 @@
export let item;
export let cardType;
import { generateURL } from '$lib/Jellyfin-api.js';
import { JellyfinUtils } from '$lib/utils'
const getAlbumCardLink = (item) => {
if (cardType === "albums") {
@@ -19,7 +19,7 @@
<a href={getAlbumCardLink(item)} style="text-decoration: none;">
<div class="image-card">
<img src="{generateURL({type: 'Image', pathParams: {'id': 'Primary' in item.ImageTags ? item.Id : item.AlbumId}})}" alt="jacket">
<img src="{JellyfinUtils.getImageEnpt('Primary' in item.ImageTags ? item.Id : item.AlbumId)}" alt="jacket">
<span>{item.Name}</span>
</div>
</a>

View File

@@ -1,11 +1,10 @@
<script>
export let item
import { generateURL } from '$lib/Jellyfin-api.js'
import { ticksToTime } from '$lib/utils.js'
import { JellyfinUtils, ticksToTime } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
$: jacketSrc = generateURL({ type: 'Image', pathParams: { id: 'Primary' in item.ImageTags ? item.Id : item.AlbumId } })
$: jacketSrc = JellyfinUtils.getImageEnpt('Primary' in item.ImageTags ? item.Id : item.AlbumId)
const dispatch = createEventDispatcher()

View File

@@ -2,10 +2,8 @@
export let currentlyPlaying
export let playlistItems
import { buildAudioEndpoint } from '$lib/audio-manager.js'
import { onMount } from 'svelte'
import { createEventDispatcher } from 'svelte'
import { generateURL } from '$lib/Jellyfin-api.js'
import { JellyfinUtils } from '$lib/utils'
import { onMount, createEventDispatcher } from 'svelte'
import { fade } from 'svelte/transition'
import ListItem from '$lib/listItem.svelte'
@@ -14,12 +12,9 @@
const dispatch = createEventDispatcher()
$: currentlyPlayingImageId = 'Primary' in currentlyPlaying.ImageTags ? currentlyPlaying.Id : currentlyPlaying.AlbumId
$: currentlyPlayingImage = generateURL({
type: 'Image',
pathParams: { id: currentlyPlayingImageId },
})
$: currentlyPlayingImage = JellyfinUtils.getImageEnpt(currentlyPlayingImageId)
$: audioEndpoint = buildAudioEndpoint(currentlyPlaying.Id, 'default')
$: audioEndpoint = JellyfinUtils.getAudioEnpt(currentlyPlaying.Id, 'default')
let audio
let audioVolume = 0.1
let progressBar

View File

@@ -0,0 +1,41 @@
<script>
export let alertType
export let alertMessage
import { onMount } from 'svelte'
import { slide } from 'svelte/transition'
import { fly } from 'svelte/transition'
import { createEventDispatcher } from 'svelte'
let show = false
const dispatch = createEventDispatcher()
const bgColors = {
info: 'bg-neutral-500',
success: 'bg-emerald-500',
caution: 'bg-amber-500',
warning: 'bg-red-500',
}
export const triggerClose = () => {
show = false
dispatch('closeAlert')
}
onMount(() => {
show = true
setTimeout(() => triggerClose(), 10000)
})
</script>
{#if show}
<div in:fly={{ duration: 300, x: 500 }} out:slide={{ duration: 300, axis: 'y' }} class="py-1">
<div class="flex gap-1 overflow-hidden rounded-md">
<div class="flex min-h-[3.5rem] w-full items-center px-4 py-2 {bgColors[alertType]}">
{alertMessage}
</div>
<button class="w-16 {bgColors[alertType]}" on:click={() => triggerClose()}>
<i class="fa-solid fa-x" />
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,30 @@
<script>
import Alert from './alert.svelte'
let alertBox
let alertQueue = []
export const addAlert = (alertType, alertMessage) => {
if (alertQueue.length > 5) {
alertQueue[0].triggerClose()
}
const alert = new Alert({
target: alertBox,
props: {
alertType: alertType,
alertMessage: alertMessage,
},
})
alert.$on('closeAlert', () => {
const index = alertQueue.indexOf(alert)
if (index > -1) alertQueue.splice(index, 1)
setTimeout(() => alert.$destroy(), 300)
})
alertQueue.push(alert)
}
</script>
<div bind:this={alertBox} class="fixed right-4 top-4 z-50 max-h-screen w-full max-w-sm overflow-hidden"></div>

View File

@@ -0,0 +1,68 @@
<script>
import { fade, slide } from 'svelte/transition'
import { spin } from '$lib/utils/animations'
import { page } from '$app/stores'
let button,
icon,
open = false
$: $page.url, closeMenu()
const closeMenu = () => {
if (button && open) {
button.animate(spin(), 400)
open = false
}
}
</script>
<div class="relative aspect-square h-full">
<button
bind:this={button}
id="button"
class="grid h-full w-full place-items-center transition-transform duration-75 active:scale-90"
on:click={() => {
button.animate(spin(), 400)
open = !open
}}
>
{#if open}
<i id="menu-icon" transition:fade={{ duration: 300 }} bind:this={icon} class="fa-solid fa-xmark" />
{:else}
<i id="menu-icon" transition:fade={{ duration: 300 }} bind:this={icon} class="fa-solid fa-bars" />
{/if}
</button>
{#if open}
<section transition:slide={{ duration: 200, axis: 'y' }} id="dropdown" class="absolute w-screen max-w-sm">
<slot name="menu-items" />
</section>
{/if}
</div>
<style>
#dropdown {
top: calc(100% + 0.6rem);
}
#button::before {
content: '';
width: 0;
height: 0;
background-color: color-mix(in srgb, var(--lazuli-primary) 20%, transparent);
border-radius: 100%;
transition-property: width height;
transition-duration: 200ms;
position: absolute;
}
#menu-icon {
font-size: 1.5rem;
position: absolute;
transition: color 200ms;
}
#button:hover > i {
color: var(--lazuli-primary);
}
#button:hover::before {
width: 130%;
height: 130%;
}
</style>

View File

@@ -0,0 +1,32 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
</script>
<button class="relative grid aspect-square h-full place-items-center transition-transform duration-75 active:scale-90" on:click={() => dispatch('click')}>
<slot name="icon" />
</button>
<style>
button::before {
content: '';
width: 0;
height: 0;
background-color: color-mix(in srgb, var(--lazuli-primary) 20%, transparent);
border-radius: 100%;
transition-property: width height;
transition-duration: 200ms;
position: absolute;
}
button:hover::before {
width: 130%;
height: 130%;
}
button :global(> :first-child) {
transition: color 200ms;
}
button:hover :global(> :first-child) {
color: var(--lazuli-primary);
}
</style>

View File

@@ -0,0 +1,48 @@
<script>
import HamburgerMenu from './hamburgerMenu.svelte'
import IconButton from './iconButton.svelte'
import SearchBar from './searchBar.svelte'
import { goto, afterNavigate } from '$app/navigation'
import { page } from '$app/stores'
import { fade } from 'svelte/transition'
let previousPage = null
afterNavigate(({ from }) => {
if (from) previousPage = from.url
})
let windowY = 0
</script>
<svelte:window bind:scrollY={windowY} />
<nav id="navbar" class="fixed left-0 top-0 isolate z-10 h-16 w-full">
<div class="grid h-full grid-cols-3">
<div class="flex h-full items-center gap-5 py-4 pl-6">
<HamburgerMenu>
<ol slot="menu-items" class="overflow-hidden rounded-lg border-2 border-neutral-800 bg-neutral-925 p-2">
<li>
<button class="w-full rounded-md px-3 py-2 text-left hover:bg-neutral-900" on:click={() => goto('/settings')}>
<i class="fa-solid fa-gear mr-1" />
Settings
</button>
</li>
</ol>
</HamburgerMenu>
{#if previousPage && $page.url.pathname !== '/'}
<IconButton on:click={() => history.back()}>
<i slot="icon" class="fa-solid fa-arrow-left text-xl" />
</IconButton>
{/if}
{#if $page.url.pathname !== '/'}
<IconButton on:click={() => goto('/')}>
<i slot="icon" class="fa-solid fa-house text-xl" />
</IconButton>
{/if}
</div>
<SearchBar />
</div>
{#if windowY > 0}
<div transition:fade={{ duration: 150 }} id="navbar-background" class="absolute left-0 top-0 -z-10 h-full w-full bg-neutral-925" />
<!-- This would be a cool place for personalization -->
{/if}
</nav>

View File

@@ -0,0 +1,76 @@
<script>
let searchBar, searchInput
let searchOpen = false
let searchRecommendations = null
const toggleSearchMenu = (open) => {
searchOpen = open
if (open) {
searchBar.style.borderColor = 'rgb(100, 100, 100)'
searchRecommendations = ['Psycho Lily', 'Iceborn', 'HYPER4ID', 'Parousia', 'Ragnarok', 'Betwixt & Between']
} else {
searchBar.style.borderColor = 'transparent'
searchRecommendations = null
}
}
const triggerSearch = (searchQuery) => {
console.log(`Search for: ${searchQuery}`)
// Redirect To '/search' route with query parameter '?query=searchQuery'
}
</script>
<search
role="search"
bind:this={searchBar}
class="relative my-2.5 flex w-full items-center gap-2.5 justify-self-center rounded-full border-2 border-transparent px-4 py-1.5"
on:focusout={() => {
setTimeout(() => {
// This is a completely stupid thing you have to do, if there is not timeout, the active element will be the body of the document and not the newly focused element
if (!searchBar.contains(document.activeElement)) {
toggleSearchMenu(false)
}
}, 1)
}}
>
<button
on:click|preventDefault={(event) => {
if (searchInput.value.trim() === '') {
if (event.pointerType === 'mouse') toggleSearchMenu(!searchOpen)
if (searchOpen) searchInput.focus()
} else {
triggerSearch(searchInput.value)
}
}}
>
<i class="fa-solid fa-magnifying-glass transition-colors duration-200 hover:text-lazuli-primary" />
</button>
<input
bind:this={searchInput}
type="text"
name="search"
class="w-full bg-transparent outline-none"
placeholder="Let's find some music"
autocomplete="off"
on:focus={() => toggleSearchMenu(true)}
on:keypress={(event) => {
if (event.key === 'Enter') triggerSearch(searchInput.value)
}}
/>
{#if searchRecommendations}
<div class="absolute left-0 top-full flex w-full flex-col bg-neutral-950">
{#each searchRecommendations as recommendation}
<button class="w-full p-4 text-left" on:click|preventDefault={() => triggerSearch(recommendation)}>
{recommendation}
</button>
{/each}
</div>
{/if}
</search>
<style>
search {
background-color: rgba(100, 100, 100, 0.25);
}
</style>

View File

@@ -0,0 +1,30 @@
<script>
export let toggled = false
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,
})
}
</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>
</div>
</button>

View File

@@ -1,16 +0,0 @@
<script>
import { onMount } from "svelte";
onMount(() => {
window.onscroll = () => {
const navbar = document.getElementById('navbar');
if (window.scrollY !== 0) {
navbar.style.backgroundColor = '#00000091';
} else if (window.scrollY == 0) {
navbar.style.backgroundColor = '#00000000';
};
};
})
</script>
<nav id="navbar" class="w-full h-16 z-10 bg-transparent fixed transition-[background_color] ease-linear duration-300"></nav>

BIN
src/lib/server/db/users.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,70 @@
import Database from 'better-sqlite3'
import Services from '$lib/services.json'
const db = new Database('./src/lib/server/db/users.db', { verbose: console.info })
db.pragma('foreign_keys = ON')
const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)'
const initUserConnectionsTable =
'CREATE TABLE IF NOT EXISTS UserConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL, serviceName VARCHAR(64) NOT NULL, accessToken TEXT, refreshToken TEXT, expiry DATETIME, FOREIGN KEY(userId) REFERENCES Users(id))'
const initJellyfinAuthTable = 'CREATE TABLE IF NOT EXISTS JellyfinConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, accesstoken TEXT, serverid TEXT)'
const initYouTubeMusicConnectionsTable = ''
const initSpotifyConnectionsTable = ''
db.exec(initUsersTable)
db.exec(initUserConnectionsTable)
export class Users {
static addUser = (username, hashedPassword) => {
try {
db.prepare('INSERT INTO Users(username, password) VALUES(?, ?)').run(username, hashedPassword)
return this.queryUsername(username)
} catch {
return null
}
}
static queryUsername = (username) => {
return db.prepare('SELECT * FROM Users WHERE lower(username) = ?').get(username.toLowerCase())
}
}
export class UserConnections {
static validServices = Object.keys(Services)
static getConnections = (userId, serviceNames = null) => {
if (!serviceNames) {
const connections = db.prepare('SELECT * FROM UserConnections WHERE userId = ?').all(userId)
if (connections.length === 0) return null
return connections
}
if (!Array.isArray(serviceNames)) {
if (typeof serviceNames !== 'string') throw new Error('Service names must be a string or array of strings')
serviceNames = [serviceNames]
}
serviceNames = serviceNames.filter((service) => this.validServices.includes(service))
const placeholders = serviceNames.map(() => '?').join(', ') // This is SQL-injection safe, the placeholders are just ?, ?, ?....
const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ? AND serviceName IN (${placeholders})`).all(userId, ...serviceNames)
if (connections.length === 0) return null
return connections
}
// May want to give accessToken a default of null in the future if one of the services does not use access tokens
static setConnection = (userId, serviceName, accessToken, refreshToken = null, expiry = null) => {
if (!this.validServices.includes(serviceName)) throw new Error(`Service name ${serviceName} is invalid`)
const existingConnection = this.getConnections(userId, serviceName)
if (existingConnection) {
db.prepare('UPDATE UserConnections SET accessToken = ?, refreshToken = ?, expiry = ? WHERE userId = ? AND serviceName = ?').run(accessToken, refreshToken, expiry, userId, serviceName)
} else {
db.prepare('INSERT INTO UserConnections(userId, serviceName, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?)').run(userId, serviceName, accessToken, refreshToken, expiry)
}
// return this.getConnections(userId, serviceName) <--- Uncomment this if want to return new connection data after update
}
static deleteConnection = (userId, serviceName) => {
const info = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND serviceName = ?').run(userId, serviceName)
if (!info.changes === 0) throw new Error(`User does not have connection: ${serviceName}`)
}
}

17
src/lib/services.json Normal file
View File

@@ -0,0 +1,17 @@
{
"jellyfin": {
"displayName": "Jellyfin",
"type": ["streaming"],
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg"
},
"youtube-music": {
"displayName": "YouTube Music",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
},
"spotify": {
"displayName": "Spotify",
"type": ["streaming"],
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg"
}
}

View File

@@ -0,0 +1,3 @@
import { writable } from 'svelte/store'
export const newestAlert = writable([null, null])

View File

@@ -1,20 +0,0 @@
export const ticksToTime = (ticks) => {
const totalSeconds = ~~(ticks / 10000000)
const totalMinutes = ~~(totalSeconds / 60)
const hours = ~~(totalMinutes / 60)
const remainderMinutes = totalMinutes - hours * 60
const remainderSeconds = totalSeconds - totalMinutes * 60
const format = (value) => {
return value < 10 ? `0${value}` : value
}
if (hours > 0) {
return `${hours}:${format(remainderMinutes)}:${format(
remainderSeconds
)}`
} else {
return `${remainderMinutes}:${format(remainderSeconds)}`
}
}

View File

@@ -0,0 +1,23 @@
export const shake = (strength = 1) => {
return {
transform: [
`translateX(-${strength}px)`,
`translateX(${strength * 2}px)`,
`translateX(-${strength * 4}px)`,
`translateX(${strength * 4}px)`,
`translateX(-${strength * 4}px)`,
`translateX(${strength * 4}px)`,
`translateX(-${strength * 4}px)`,
`translateX(${strength * 2}px)`,
`translateX(-${strength}px)`,
],
offset: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
}
}
export const spin = (rotations = 1) => {
return [
{ rotate: '0deg', easing: 'ease-in-out' },
{ rotate: `${rotations * 360}deg`, easing: 'ease-in-out' },
]
}

71
src/lib/utils/utils.js Normal file
View File

@@ -0,0 +1,71 @@
export const ticksToTime = (ticks) => {
const totalSeconds = ~~(ticks / 10000000)
const totalMinutes = ~~(totalSeconds / 60)
const hours = ~~(totalMinutes / 60)
const remainderMinutes = totalMinutes - hours * 60
const remainderSeconds = totalSeconds - totalMinutes * 60
const format = (value) => {
return value < 10 ? `0${value}` : value
}
if (hours > 0) {
return `${hours}:${format(remainderMinutes)}:${format(remainderSeconds)}`
} else {
return `${remainderMinutes}:${format(remainderSeconds)}`
}
}
export class JellyfinUtils {
static #ROOT_URL = 'http://eclypsecloud:8096/'
static #API_KEY = 'fd4bf4c18e5f4bb08c2cb9f6a1542118'
static #USER_ID = '7364ce5928c64b90b5765e56ca884053'
static #AUDIO_PRESETS = {
default: {
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,
},
}
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 getItemsEnpt(itemParams) {
const baseUrl = this.#ROOT_URL + `Users/${this.#USER_ID}/Items`
const endpoint = this.#buildUrl(baseUrl, itemParams)
return endpoint
}
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() {
const existingUUID = localStorage.getItem('lazuliDeviceUUID')
if (!existingUUID) {
const newUUID = '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
localStorage.setItem('lazuliDeviceUUID', newUUID)
return newUUID
}
return existingUUID
}
}

View File

@@ -1,6 +1,57 @@
<script>
import '../app.css'
import '@fortawesome/fontawesome-free/css/all.min.css'
import Navbar from '$lib/components/utility/navbar.svelte'
import AlertBox from '$lib/components/utility/alertBox.svelte'
import { page } from '$app/stores'
import { newestAlert } from '$lib/stores/alertStore.js'
import { fade } from 'svelte/transition'
import { onMount } from 'svelte'
let alertBox
$: addAlert($newestAlert)
const addAlert = (alertData) => {
if (alertBox) alertBox.addAlert(...alertData)
}
// Might want to change this functionallity to a fetch/preload/await for the image
const backgroundImage = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // <-- Default youtube music background
let loaded = false
onMount(() => (loaded = true))
</script>
<slot />
{#if $page.url.pathname === '/api'}
<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>
<Navbar />
<div class="h-full pt-16">
<slot />
</div>
{/if}
<AlertBox bind:this={alertBox} />
</main>
{/if}
<style>
#background-gradient {
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.6), black);
}
#background-image {
mask-image: linear-gradient(to bottom, black, rgba(0, 0, 0, 0.3));
}
</style>

View File

@@ -0,0 +1,6 @@
/** @type {import('./$types').PageServerLoad} */
export const load = ({ locals }) => {
return {
user: locals.user,
}
}

View File

@@ -1,2 +1,5 @@
<h1 class="underline text-green-400">Welcome to SvelteKit</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
<script>
export let data
const connectionsData = data.connections
</script>

View File

@@ -0,0 +1,11 @@
export async function load({ fetch, params }) {
const albumId = params.id
const response = await fetch(`/api/jellyfin/album?albumId=${albumId}`)
const responseData = await response.json()
return {
id: albumId,
albumItemsData: responseData.albumItems,
albumData: responseData.albumData,
}
}

View File

@@ -2,7 +2,7 @@
import { fly, slide } from 'svelte/transition'
import { cubicIn, cubicOut } from 'svelte/easing'
import { generateURL } from '$lib/Jellyfin-api.js'
import { JellyfinUtils } from '$lib/utils'
import AlbumBg from '$lib/albumBG.svelte'
import Navbar from '$lib/navbar.svelte'
import ListItem from '$lib/listItem.svelte'
@@ -10,8 +10,7 @@
import MediaPlayer from '$lib/mediaPlayer.svelte'
export let data
let albumImg = generateURL({ type: 'Image', pathParams: { id: data.id } })
// console.log(generateURL({type: 'Items', queryParams: {'albumIds': data.albumData.Id, 'recursive': true}}))
let albumImg = JellyfinUtils.getImageEnpt(data.id)
let discArray = Array.from({ length: data.albumData?.discCount ? data.albumData.discCount : 1 }, (_, i) => {
return data.albumItemsData.filter((item) => item?.ParentIndexNumber === i + 1 || !item?.ParentIndexNumber)
})

View File

@@ -1,40 +1,46 @@
import { generateURL } from '$lib/Jellyfin-api.js'
export async function load({ fetch, params }) {
const response = await fetch(
generateURL({
type: 'Items',
queryParams: { albumIds: params.id, recursive: true },
})
)
const albumItemsData = await response.json()
// This handles rare circumstances where a song is part of an album but is not meant to be included in the track list
// Example: Xronial Xero (Laur Remix) - Is tagged with the album Xronial Xero, but was a bonus track and not included as part of the album's track list.
const items = albumItemsData.Items.filter((item) => 'IndexNumber' in item)
// Idk if it's efficient, but this is a beautiful one liner that accomplishes 1. Checking whether or not there are multiple discs, 2. Sorting the Items
// primarily by disc number, and secondarily by track number, and 3. Defaulting to just sorting by track number if the album is only one disc.
items.sort((a, b) =>
a?.ParentIndexNumber !== b?.ParentIndexNumber
? a.ParentIndexNumber - b.ParentIndexNumber
: a.IndexNumber - b.IndexNumber
)
const albumData = {
name: items[0].Album,
id: items[0].AlbumId,
artists: items[0].AlbumArtists,
year: items[0].ProductionYear,
discCount: Math.max(...items.map((x) => x?.ParentIndexNumber)),
length: items
.map((x) => x.RunTimeTicks)
.reduce((accumulator, currentValue) => accumulator + currentValue),
}
return {
id: params.id,
albumItemsData: items,
albumData: albumData,
}
}
import { JellyfinUtils } from '$lib/utils'
/** @type {import('./$types').RequestHandler} */
export async function GET({ url, fetch }) {
const albumId = url.searchParams.get('albumId')
if (!albumId) {
return new Response('Requires albumId Query Parameter', { status: 400 })
}
const endpoint = JellyfinUtils.getItemsEnpt({ albumIds: albumId, recursive: true })
const response = await fetch(endpoint)
const data = await response.json()
// This handles rare circumstances where a song is part of an album but is not meant to be included in the track list
// Example: Xronial Xero (Laur Remix) - Is tagged with the album Xronial Xero, but was a bonus track and not included as part of the album's track list.
const items = data.Items.filter((item) => 'IndexNumber' in item)
// Idk if it's efficient, but this is a beautiful one liner that accomplishes 1. Checking whether or not there are multiple discs, 2. Sorting the Items
// primarily by disc number, and secondarily by track number, and 3. Defaulting to just sorting by track number if the album is only one disc.
items.sort((a, b) =>
a?.ParentIndexNumber !== b?.ParentIndexNumber
? a.ParentIndexNumber - b.ParentIndexNumber
: a.IndexNumber - b.IndexNumber
)
const albumData = {
name: items[0].Album,
id: albumId,
artists: items[0].AlbumArtists,
year: items[0].ProductionYear,
discCount: Math.max(...items.map((x) => x?.ParentIndexNumber)),
length: items
.map((x) => x.RunTimeTicks)
.reduce((accumulator, currentValue) => accumulator + currentValue),
}
const responseData = JSON.stringify({
albumItems: items,
albumData: albumData,
})
const responseHeaders = new Headers({
'Content-Type': 'application/json',
})
return new Response(responseData, {headers: responseHeaders})
}

View File

@@ -0,0 +1,39 @@
import { JellyfinUtils } from '$lib/utils'
/** @type {import('./$types').RequestHandler} */
export async function GET({ url, fetch }) {
const artistId = url.searchParams.get('artistId')
if (!artistId) {
return new Response('Requires artistId Query Parameter', { status: 400 })
}
const endpoint = JellyfinUtils.getItemsEnpt({ artistIds: artistId, recursive: true })
const response = await fetch(endpoint)
const data = await response.json()
const artistItems = {
albums: [],
singles: [],
appearances: [],
}
// Filters the raw list of items to only the albums that were produced fully or in part by the specified artist
artistItems.albums = data.Items.filter((item) => item.Type === 'MusicAlbum' && item.AlbumArtists.some((artist) => artist.Id === artistId))
data.Items.forEach((item) => {
if (item.Type === 'Audio') {
if (!('AlbumId' in item)) {
artistItems.singles.push(item)
} else if (!artistItems.albums.some((album) => album.Id === item.AlbumId)) {
artistItems.appearances.push(item)
}
}
})
const responseData = JSON.stringify(artistItems)
const responseHeaders = new Headers({
'Content-Type': 'application/json',
})
return new Response(responseData, { headers: responseHeaders })
}

View File

@@ -0,0 +1,42 @@
/** @type {import('./$types').RequestHandler} */
export async function GET({ url, fetch }) {
const { serverUrl, username, password, deviceId } = Object.fromEntries(url.searchParams)
if (!(serverUrl && username && password && deviceId)) return new Response('Missing authentication parameter', { status: 400 })
let authResponse
try {
const authUrl = new URL('/Users/AuthenticateByName', serverUrl).href
authResponse = await fetch(authUrl, {
method: 'POST',
body: JSON.stringify({
Username: username,
Pw: password,
}),
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="1.0.0.0"`,
},
})
} catch {
authResponse = new Response('Invalid server URL', { status: 400 })
}
if (!authResponse.ok) {
authResponse = (await authResponse.text()) === 'Error processing request.' ? new Response('Invalid credentials', { status: 400 }) : authResponse
return authResponse
}
if (!authResponse.headers.get('content-type').includes('application/json')) return new Response('Jellyfin server returned invalid data', { status: 500 })
const data = await authResponse.json()
const requiredData = ['User', 'SessionInfo', 'AccessToken', 'ServerId']
if (!requiredData.every((key) => Object.keys(data).includes(key))) return new Response('Data missing from Jellyfin server response', { status: 500 })
const responseData = JSON.stringify(data)
const responseHeaders = new Headers({
'Content-Type': 'application/json',
})
return new Response(responseData, { headers: responseHeaders })
}

View File

@@ -0,0 +1,20 @@
import { JellyfinUtils } from '$lib/utils'
/** @type {import('./$types').RequestHandler} */
export async function GET({ url, fetch }) {
const songId = url.searchParams.get('songId')
if (!artistId) {
return new Response('Requires songId Query Parameter', { status: 400 })
}
const endpoint = JellyfinUtils.getItemsEnpt({ ids: songId, recursive: true })
const response = await fetch(endpoint)
const data = await response.json()
const responseData = JSON.stringify(data.Items[0])
const responseHeaders = new Headers({
'Content-Type': 'application/json',
})
return new Response(responseData, {headers: responseHeaders})
}

View File

@@ -0,0 +1,67 @@
import { UserConnections } from '$lib/server/db/users'
import Joi from 'joi'
/** @type {import('./$types').RequestHandler} */
export async function GET({ request, url }) {
const schema = Joi.number().required()
const userId = request.headers.get('userId')
const validation = schema.validate(userId)
if (validation.error) return new Response(validation.error.message, { status: 400 })
const responseHeaders = new Headers({
'Content-Type': 'application/json',
})
const filter = url.searchParams.get('filter')
if (filter) {
const requestedConnections = filter.split(',').map((item) => item.toLowerCase())
const userConnections = UserConnections.getConnections(userId, requestedConnections)
return new Response(JSON.stringify(userConnections), { headers: responseHeaders })
}
const userConnections = UserConnections.getConnections(userId)
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 }) {
const schema = Joi.object({
userId: Joi.number().required(),
connection: Joi.object({
serviceName: Joi.string().required(),
accessToken: Joi.string().required(),
}).required(),
})
const userId = request.headers.get('userId')
const connection = await request.json()
const validation = schema.validate({ userId, connection })
if (validation.error) return new Response(validation.error.message, { status: 400 })
UserConnections.setConnection(userId, connection.serviceName, connection.accessToken)
return new Response('Updated Connection')
}
/** @type {import('./$types').RequestHandler} */
export async function DELETE({ request }) {
const schema = Joi.object({
userId: Joi.number().required(),
connection: Joi.object({
serviceName: Joi.string().required(),
}).required(),
})
const userId = request.headers.get('userId')
const connection = await request.json()
const validation = schema.validate({ userId, connection })
if (validation.error) return new Response(validation.error.message, { status: 400 })
UserConnections.deleteConnection(userId, connection.serviceName)
return new Response('Deleted Connection')
}

View File

@@ -0,0 +1,27 @@
import ytdl from 'ytdl-core'
/** @type {import('./$types').RequestHandler} */
export async function GET({ url }) {
const videoId = url.searchParams.get('videoId')
if (!videoId) {
return new Response('Requires videoId Query Parameter', { status: 400 })
}
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`
const info = await ytdl.getInfo(videoUrl)
const videoFormat = ytdl.chooseFormat(info.formats, {
filter: (format) => format.hasVideo && !format.isDashMPD && 'contentLength' in format,
quality: 'highestvideo',
})
const audioFormat = ytdl.chooseFormat(info.formats, {
filter: 'audioonly',
quality: 'highestaudio',
})
const responseData = JSON.stringify({ video: videoFormat.url, audio: audioFormat.url })
const responseHeaders = new Headers({
'Content-Type': 'application/json',
})
return new Response(responseData, { headers: responseHeaders })
}

View File

@@ -1,5 +0,0 @@
export const load = ({ params }) => {
return {
id: params.id,
}
}

View File

@@ -0,0 +1,10 @@
export async function load({ params, fetch }) {
const artistId = params.id
const response = await fetch(`/api/jellyfin/artist?artistId=${artistId}`)
const responseData = await response.json()
return {
id: artistId,
artistItems: responseData,
}
}

View File

@@ -1,18 +1,8 @@
<script>
import { onMount } from 'svelte';
import { fetchArtistItems } from '$lib/Jellyfin-api.js';
import AlbumCard from '$lib/albumCard.svelte';
export let data;
let artistItems = {
albums: [],
singles: [],
appearances: []
};
onMount(async () => {
artistItems = await fetchArtistItems(data.id);
})
const artistItems = data.artistItems
</script>
<div class="grid">
@@ -30,6 +20,9 @@
<AlbumCard {item} cardType="appearances"/>
{ /each }
</div>
<div>
Test
</div>
<style>
.grid {

View File

@@ -0,0 +1,71 @@
import { SECRET_JWT_KEY } from '$env/static/private'
import { fail, redirect } from '@sveltejs/kit'
import { Users } from '$lib/server/db/users'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
/** @type {import('./$types').PageServerLoad} */
export const load = ({ url }) => {
const redirectLocation = url.searchParams.get('redirect')
return { redirectLocation: redirectLocation }
}
/** @type {import('./$types').Actions}} */
export const actions = {
signIn: async ({ request, cookies }) => {
const formData = await request.formData()
const { username, password, redirectLocation } = Object.fromEntries(formData)
const user = Users.queryUsername(username)
if (!user) return fail(400, { message: 'Invalid Username' })
const validPassword = await bcrypt.compare(password, user.password)
if (!validPassword) return fail(400, { message: 'Invalid Password' })
const authToken = jwt.sign(
{
id: user.id,
user: user.username,
},
SECRET_JWT_KEY,
{ expiresIn: '100d' },
)
cookies.set('lazuli-auth', authToken, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: false,
maxAge: 60 * 60 * 24 * 100, // 100 Days
})
if (redirectLocation) throw redirect(303, redirectLocation)
throw redirect(303, '/')
},
newUser: async ({ request, cookies }) => {
const formData = await request.formData()
const { username, password } = Object.fromEntries(formData)
const passwordHash = await bcrypt.hash(password, 10)
const newUser = Users.addUser(username, passwordHash)
if (!newUser) return fail(400, { message: 'Username already exists' })
const authToken = jwt.sign(
{
id: newUser.id,
user: newUser.username,
},
SECRET_JWT_KEY,
{ expiresIn: '100d' },
)
cookies.set('lazuli-auth', authToken, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: false,
maxAge: 60 * 60 * 24 * 100, // 100 Days
})
throw redirect(303, '/')
},
}

View File

@@ -0,0 +1,91 @@
<script>
import { enhance } from '$app/forms'
import { goto } from '$app/navigation'
import { fade } from 'svelte/transition'
import { newestAlert } from '$lib/stores/alertStore.js'
export let data
let formMode = 'signIn'
const handleForm = ({ formData, cancel, action }) => {
const actionType = action.search.substring(2)
if (actionType !== formMode) {
cancel()
return (formMode = formMode === 'signIn' ? 'newUser' : 'signIn')
}
const { username, password, confirmPassword } = Object.fromEntries(formData)
if (!username || !password || (formMode === 'newUser' && !confirmPassword)) {
cancel()
return ($newestAlert = ['caution', 'All fields must be filled out'])
}
if (formMode === 'newUser') {
if (username.length > 50) {
cancel()
return ($newestAlert = ['caution', 'Really? You need a username longer that 50 characters? No, stop it, be normal'])
}
if (password.length < 8) {
cancel()
return ($newestAlert = ['caution', 'Password must be at least 8 characters long'])
}
if (password !== confirmPassword) {
cancel()
return ($newestAlert = ['caution', 'Password and Confirm Password must match'])
}
}
if (data.redirectLocation) formData.append('redirectLocation', data.redirectLocation)
return async ({ result }) => {
if (result.type === 'redirect') {
goto(result.location)
} else if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data.message])
}
}
}
</script>
<div class="flex h-full items-center justify-center">
<div class="w-full max-w-4xl rounded-2xl p-8">
<section class="flex h-14 justify-center">
{#key formMode}
<span class="absolute text-5xl" transition:fade={{ duration: 100 }}>{formMode === 'signIn' ? 'Sign In' : 'Create New User'}</span>
{/key}
</section>
<form method="post" on:submit|preventDefault use:enhance={handleForm}>
<section>
<div class="p-4">
<input name="username" type="text" autocomplete="off" placeholder="Username" class="h-10 w-full border-b-2 border-lazuli-primary bg-transparent px-1 outline-none" />
</div>
<div class="flex">
<div class="w-full p-4">
<input name="password" type="password" placeholder="Password" class="h-10 w-full border-b-2 border-lazuli-primary bg-transparent px-1 outline-none" />
</div>
<div class="overflow-hidden py-4 transition-[width] duration-300 ease-linear" style="width: {formMode === 'newUser' ? '100%' : 0};" aria-hidden={formMode !== 'newUser'}>
<div class="px-4">
<input name="confirmPassword" type="password" placeholder="Confirm Password" class="block h-10 w-full border-b-2 border-lazuli-primary bg-transparent px-1 outline-none" />
</div>
</div>
</div>
</section>
<section class="mt-6 flex items-center justify-around">
<button formaction="?/signIn" class="h-12 w-1/3 rounded-md transition-all active:scale-[97%]" style="background-color: {formMode === 'signIn' ? 'var(--lazuli-primary)' : '#262626'};">
Sign In
<i class="fa-solid fa-right-to-bracket ml-1" />
</button>
<button
formaction="?/newUser"
class="h-12 w-1/3 rounded-md transition-all active:scale-[97%]"
style="background-color: {formMode === 'newUser' ? 'var(--lazuli-primary)' : '#262626'};"
>
Create New User
<i class="fa-solid fa-user-plus ml-1" />
</button>
</section>
</form>
</div>
</div>

View File

@@ -0,0 +1,68 @@
<script>
import IconButton from '$lib/components/utility/iconButton.svelte'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
const settingRoutes = {
connections: {
displayName: 'Connections',
uri: '/settings/connections',
icon: 'fa-solid fa-circle-nodes',
},
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">
<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'}
<IconButton on:click={() => goto('/settings')}>
<i slot="icon" class="fa-solid fa-caret-left" />
</IconButton>
{/if}
</h1>
<ol class="ml-2 mt-4 flex flex-col gap-3 border-2 border-transparent border-l-neutral-500 px-2">
{#each Object.values(settingRoutes) as route}
<li>
{#if $page.url.pathname === route.uri}
<div class="rounded-lg bg-neutral-500 px-3 py-1">
<i class={route.icon} />
{route.displayName}
</div>
{:else}
<a href={route.uri} class="block rounded-lg px-3 py-1 hover:bg-neutral-700">
<i class={route.icon} />
{route.displayName}
</a>
{/if}
</li>
{/each}
</ol>
</nav>
<div class="relative h-full overflow-y-scroll rounded-lg">
<slot />
</div>
</main>
<style>
main {
grid-template-columns: 20rem auto;
}
nav {
background-color: rgba(82, 82, 82, 0.25);
}
i {
text-align: center;
width: 1rem;
margin-right: 0.2rem;
}
</style>

View File

@@ -0,0 +1,13 @@
<!-- This is template for reference -->
<script>
</script>
<section></section>
<style>
section {
border-radius: 0.5rem;
background-color: rgba(82, 82, 82, 0.25);
height: 24rem;
}
</style>

View File

@@ -0,0 +1,76 @@
import { fail } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
/** @type {import('./$types').PageServerLoad} */
export const load = async ({ fetch, locals }) => {
const response = await fetch('/api/user/connections', {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId,
},
})
if (response.ok) {
const connectionsData = await response.json()
if (connectionsData) {
const serviceNames = connectionsData.map((connection) => connection.serviceName)
return { existingConnections: serviceNames }
}
} else {
const error = await response.text()
console.log(error)
}
}
/** @type {import('./$types').Actions}} */
export const actions = {
authenticateJellyfin: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const queryParams = new URLSearchParams()
for (let field of formData) {
const [key, value] = field
queryParams.append(key, value)
}
const jellyfinAuthResponse = await fetch(`/api/jellyfin/auth?${queryParams.toString()}`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
})
if (!jellyfinAuthResponse.ok) {
const jellyfinAuthError = await jellyfinAuthResponse.text()
return fail(jellyfinAuthResponse.status, { message: jellyfinAuthError })
}
const jellyfinAuthData = await jellyfinAuthResponse.json()
const jellyfinAccessToken = jellyfinAuthData.AccessToken
const updateConnectionsResponse = await fetch('/api/user/connections', {
method: 'PATCH',
headers: {
apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId,
},
body: JSON.stringify({ serviceName: 'jellyfin', accessToken: jellyfinAccessToken }),
})
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { message: 'Updated Jellyfin connection' }
},
deleteConnection: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const serviceName = formData.get('service')
const deleteConnectionResponse = await fetch('/api/user/connections', {
method: 'DELETE',
headers: {
apikey: SECRET_INTERNAL_API_KEY,
userId: locals.userId,
},
body: JSON.stringify({ serviceName }),
})
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { message: 'Connection deleted' }
},
}

View File

@@ -0,0 +1,172 @@
<script>
import { enhance } from '$app/forms'
import { fly } from 'svelte/transition'
import { JellyfinUtils } from '$lib/utils/utils'
import Services from '$lib/services.json'
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/stores/alertStore.js'
import IconButton from '$lib/components/utility/iconButton.svelte'
import Toggle from '$lib/components/utility/toggle.svelte'
import { onMount } from 'svelte'
export let data
let existingConnections = data?.existingConnections
const testServices = {
jellyfin: {
displayName: 'Jellyfin',
type: ['streaming'],
icon: 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg',
},
'youtube-music': {
displayName: 'YouTube Music',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg',
},
spotify: {
displayName: 'Spotify',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg',
},
'apple-music': {
displayName: 'Apple Music',
type: ['streaming', 'marketplace'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/5/5f/Apple_Music_icon.svg',
},
bandcamp: {
displayName: 'bandcamp',
type: ['marketplace', 'streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Bandcamp-button-bc-circle-aqua.svg',
},
soundcloud: {
displayName: 'SoundCloud',
type: ['streaming'],
icon: 'https://www.vectorlogo.zone/logos/soundcloud/soundcloud-icon.svg',
},
lastfm: {
displayName: 'Last.fm',
type: ['analytics'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/c/c4/Lastfm.svg',
},
plex: {
displayName: 'Plex',
type: ['streaming'],
icon: 'https://www.vectorlogo.zone/logos/plextv/plextv-icon.svg',
},
deezer: {
displayName: 'deezer',
type: ['streaming'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6b/Deezer_Icon.svg',
},
'amazon-music': {
displayName: 'Amazon Music',
type: ['streaming', 'marketplace'],
icon: 'https://upload.wikimedia.org/wikipedia/commons/9/92/Amazon_Music_logo.svg',
},
}
const serviceAuthenticationMethods = {}
let formMode = null
const submitCredentials = ({ formData, action, cancel }) => {
switch (action.search) {
case '?/authenticateJellyfin':
const { serverUrl, username, password } = Object.fromEntries(formData)
if (!(serverUrl && username && password)) {
cancel()
return ($newestAlert = ['caution', 'All fields must be filled out'])
}
try {
new URL(serverUrl)
} catch {
cancel()
return ($newestAlert = ['caution', 'Server URL is invalid'])
}
const deviceId = JellyfinUtils.getLocalDeviceUUID()
formData.append('deviceId', deviceId)
break
case '?/deleteConnection':
const connection = formData.get('service')
$newestAlert = ['info', `Delete ${connection}`]
cancel()
break
default:
cancel()
}
return async ({ result }) => {
switch (result.type) {
case 'failure':
return ($newestAlert = ['warning', result.data.message])
case 'success':
formMode = null
return ($newestAlert = ['success', result.data.message])
}
}
}
let modal
</script>
<main class="h-full">
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
<h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4">
{#each Object.entries(testServices) as [serviceType, serviceData]}
{#if !existingConnections.includes(serviceType)}
<button class="bg-ne h-14 rounded-md" style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));" on:click={() => (modal = JellyfinAuthBox)}>
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" />
</button>
{/if}
{/each}
</div>
</section>
{#if existingConnections}
<div class="grid gap-8">
{#each existingConnections as connectionType}
{@const service = Services[connectionType]}
<section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" in:fly={{ duration: 300, x: 50 }}>
<header class="flex h-20 items-center gap-4 p-4">
<img src={service.icon} alt="{service.displayName} icon" class="aspect-square h-full p-1" />
<div>
<div>Account Name</div>
<div class="text-sm text-neutral-500">{service.displayName}</div>
</div>
<div class="ml-auto h-8">
<IconButton on:click={() => (modal = `delete-${connectionType}`)}>
<i slot="icon" class="fa-solid fa-link-slash" />
</IconButton>
</div>
</header>
<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)} />
<span>Enable Connection</span>
</div>
</div>
</section>
{/each}
</div>
{/if}
{#if modal}
<form method="post" use:enhance={submitCredentials} transition:fly={{ duration: 300, y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{#if typeof modal === 'string'}
{@const connectionType = modal.replace('delete-', '')}
{@const service = Services[connectionType]}
<div class="rounded-lg bg-neutral-900 p-5">
<h1 class="pb-4 text-center">Delete {service.displayName}?</h1>
<div class="flex w-60 justify-around">
<input type="hidden" name="service" value={connectionType} />
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click={() => (modal = null)}>Cancel</button>
<button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button>
</div>
</div>
{:else}
<svelte:component this={modal} on:close={() => (modal = null)} />
{/if}
</form>
{/if}
</main>

View File

@@ -0,0 +1,55 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
const dispatchClose = () => {
dispatch('close')
}
</script>
<div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-900 px-8">
<h1 class="text-center text-4xl">Jellyfin Sign In</h1>
<div class="flex w-full flex-col gap-5">
<input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" />
<div class="flex w-full flex-row gap-4">
<input type="text" name="username" autocomplete="off" placeholder="Username" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" />
<input type="password" name="password" placeholder="Password" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" />
</div>
</div>
<div class="flex items-center justify-around text-lg">
<button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-[97%]" on:click|preventDefault={dispatchClose}>Cancel</button>
<button id="submit-button" type="submit" class="bg-jellyfin-blue w-1/3 rounded py-2 transition-all active:scale-[97%]" formaction="?/authenticateJellyfin">Submit</button>
</div>
</div>
<style>
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes rotation {
0% {
--gradient-angle: 0deg;
}
100% {
--gradient-angle: 360deg;
}
}
#main-box::before {
content: '';
position: absolute;
inset: -0.1rem;
z-index: -1;
background: conic-gradient(from var(--gradient-angle), var(--jellyfin-purple), var(--jellyfin-blue), var(--jellyfin-purple));
border-radius: inherit;
animation: rotation 15s linear infinite;
filter: blur(0.5rem);
}
#cancel-button:hover {
background-color: rgb(30 30 30);
}
#submit-button:hover {
background-color: color-mix(in srgb, var(--jellyfin-blue) 80%, black);
}
</style>

View File

@@ -1,5 +0,0 @@
export const load = ({ params }) => {
return {
id: params.id,
}
}

View File

@@ -0,0 +1,10 @@
export async function load ({ params, fetch }) {
const songId = params.id
const response = await fetch(`/api/jellyfin/song?songId=${songId}`)
const responseData = await response.json()
return {
id: songId,
songData: responseData
}
}

View File

@@ -1,25 +1,9 @@
<script>
import { onMount } from 'svelte';
import { fetchSong } from '$lib/Jellyfin-api.js'
export let data;
let fetchedData = {};
onMount(async () => {
fetchedData = await fetchSong(data.id);
});
const songData = data.songData
</script>
<div>
{ #await fetchedData}
<p>Loading</p>
{:then songData}
<p>{songData.Name}</p>
{/await}
</div>
<style>
</style>
<p>{songData.Name}</p>
</div>

View File

@@ -0,0 +1,11 @@
/** @type {import('./$types').PageServerLoad} */
export async function load({ url, fetch }) {
const videoId = url.searchParams.get('videoId')
const response = await fetch(`/api/yt/media?videoId=${videoId}`)
const responseData = await response.json()
return {
videoId: videoId,
videoUrl: responseData.video,
audioUrl: responseData.audio,
}
}

View File

@@ -0,0 +1,24 @@
<script>
import { onMount } from 'svelte'
export let data
let videoElement
let audioElement
onMount(() => {
audioElement.volume = 0.3
})
</script>
<video controls bind:this={videoElement} preload="auto">
<source src={data.videoUrl} type="video/mp4" />
<track kind="captions" />
</video>
<audio controls bind:this={audioElement}
on:play={videoElement.play()}
on:pause={videoElement.pause()}
on:seeked={(videoElement.currentTime = audioElement.currentTime)} preload="auto">
<source src={data.audioUrl} type="audio/webm" />
</audio>