Gonna try to start using git properly
This commit is contained in:
@@ -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>
|
||||
|
||||
6
src/routes/+page.server.js
Normal file
6
src/routes/+page.server.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('./$types').PageServerLoad} */
|
||||
export const load = ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
11
src/routes/album/[id]/+page.server.js
Normal file
11
src/routes/album/[id]/+page.server.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
39
src/routes/api/jellyfin/artist/+server.js
Normal file
39
src/routes/api/jellyfin/artist/+server.js
Normal 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 })
|
||||
}
|
||||
42
src/routes/api/jellyfin/auth/+server.js
Normal file
42
src/routes/api/jellyfin/auth/+server.js
Normal 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 })
|
||||
}
|
||||
20
src/routes/api/jellyfin/song/+server.js
Normal file
20
src/routes/api/jellyfin/song/+server.js
Normal 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})
|
||||
}
|
||||
67
src/routes/api/user/connections/+server.js
Normal file
67
src/routes/api/user/connections/+server.js
Normal 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')
|
||||
}
|
||||
27
src/routes/api/youtube-music/media/+server.js
Normal file
27
src/routes/api/youtube-music/media/+server.js
Normal 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 })
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
id: params.id,
|
||||
}
|
||||
}
|
||||
10
src/routes/artist/[id]/+page.server.js
Normal file
10
src/routes/artist/[id]/+page.server.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
71
src/routes/login/+page.server.js
Normal file
71
src/routes/login/+page.server.js
Normal 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, '/')
|
||||
},
|
||||
}
|
||||
91
src/routes/login/+page.svelte
Normal file
91
src/routes/login/+page.svelte
Normal 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>
|
||||
68
src/routes/settings/+layout.svelte
Normal file
68
src/routes/settings/+layout.svelte
Normal 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>
|
||||
13
src/routes/settings/+page.svelte
Normal file
13
src/routes/settings/+page.svelte
Normal 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>
|
||||
76
src/routes/settings/connections/+page.server.js
Normal file
76
src/routes/settings/connections/+page.server.js
Normal 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' }
|
||||
},
|
||||
}
|
||||
172
src/routes/settings/connections/+page.svelte
Normal file
172
src/routes/settings/connections/+page.svelte
Normal 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>
|
||||
55
src/routes/settings/connections/jellyfinAuthBox.svelte
Normal file
55
src/routes/settings/connections/jellyfinAuthBox.svelte
Normal 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>
|
||||
@@ -1,5 +0,0 @@
|
||||
export const load = ({ params }) => {
|
||||
return {
|
||||
id: params.id,
|
||||
}
|
||||
}
|
||||
10
src/routes/song/[id]/+page.server.js
Normal file
10
src/routes/song/[id]/+page.server.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
11
src/routes/youtube-music/+page.server.js
Normal file
11
src/routes/youtube-music/+page.server.js
Normal 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,
|
||||
}
|
||||
}
|
||||
24
src/routes/youtube-music/+page.svelte
Normal file
24
src/routes/youtube-music/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user