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

@@ -2,7 +2,7 @@
"tabWidth": 4,
"singleQuote": true,
"semi": false,
"printWidth": 250,
"printWidth": 220,
"bracketSpacing": true,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [

View File

@@ -8,3 +8,4 @@ last.fm
MusicBrainz
discogs marketplace
bandcamp
Plex

1162
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,15 +12,21 @@
"@sveltejs/kit": "^1.20.4",
"autoprefixer": "^10.4.15",
"postcss": "^8.4.28",
"prettier": "^3.0.3",
"prettier-plugin-svelte": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.4",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-tailwindcss": "^0.5.10",
"svelte": "^4.0.5",
"tailwindcss": "^3.3.3",
"vite": "^4.4.2"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.2"
"@fortawesome/fontawesome-free": "^6.4.2",
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.1.1",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"youtubei.js": "^7.0.0",
"ytdl-core": "^4.11.5"
}
}

17
problems.txt Normal file
View File

@@ -0,0 +1,17 @@
Big Problems:
- The stream services being utilized still need to be able to gather which songs you are listening to to provide recommendations,
so requests need to be proxied through the actual streaming service.
- Authentication for every other potential streaming service:
- YouTube Music:? https://ytmusicapi.readthedocs.io/en/stable/setup/oauth.html
- Spotify: https://developer.spotify.com/documentation/web-api/concepts/authorization
Little Problems:
- Video and audio need to be kept in sync, accounting for buffering and latency.
Fixed Problems:
- Fucking Jellyfin Authentication (Missing Header)
- Continuous session verification (Fixed with JWTs)
Looking for Style Guidlines?:
- URL structure: https://developers.google.com/search/docs/crawling-indexing/url-structure
- API best practicies: https://learn.microsoft.com/en-us/azure/architecture/best-practices/api-design

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,17 +1,19 @@
import { generateURL } from '$lib/Jellyfin-api.js'
import { JellyfinUtils } from '$lib/utils'
export async function load({ fetch, params }) {
const response = await fetch(
generateURL({
type: 'Items',
queryParams: { albumIds: params.id, recursive: true },
})
)
const albumItemsData = await response.json()
/** @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 = albumItemsData.Items.filter((item) => 'IndexNumber' in item)
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.
@@ -23,7 +25,7 @@ export async function load({ fetch, params }) {
const albumData = {
name: items[0].Album,
id: items[0].AlbumId,
id: albumId,
artists: items[0].AlbumArtists,
year: items[0].ProductionYear,
discCount: Math.max(...items.map((x) => x?.ParentIndexNumber)),
@@ -32,9 +34,13 @@ export async function load({ fetch, params }) {
.reduce((accumulator, currentValue) => accumulator + currentValue),
}
return {
id: params.id,
albumItemsData: items,
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}
<p>{songData.Name}</p>
</div>
<style>
</style>

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>

View File

@@ -6,10 +6,13 @@ export default {
theme: {
extend: {
fontFamily: {
notoSans: [
"'Noto Sans', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans SC', 'Noto Sans TC'",
...defaultTheme.fontFamily.sans,
],
notoSans: ["'Noto Sans', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans SC', 'Noto Sans TC'", ...defaultTheme.fontFamily.sans],
},
colors: {
'lazuli-primary': '#ed6713',
'neutral-925': 'rgb(16, 16, 16)',
'jellyfin-purple': '#aa5cc3',
'jellyfin-blue': '#00a4dc',
},
},
},