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

@@ -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>