Gonna try to start using git properly
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"printWidth": 250,
|
||||
"printWidth": 220,
|
||||
"bracketSpacing": true,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
|
||||
@@ -8,3 +8,4 @@ last.fm
|
||||
MusicBrainz
|
||||
discogs marketplace
|
||||
bandcamp
|
||||
Plex
|
||||
1162
package-lock.json
generated
1162
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -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
17
problems.txt
Normal 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
|
||||
24
src/app.css
24
src/app.css
@@ -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
27
src/hooks.server.js
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
41
src/lib/components/utility/alert.svelte
Normal file
41
src/lib/components/utility/alert.svelte
Normal 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}
|
||||
30
src/lib/components/utility/alertBox.svelte
Normal file
30
src/lib/components/utility/alertBox.svelte
Normal 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>
|
||||
68
src/lib/components/utility/hamburgerMenu.svelte
Normal file
68
src/lib/components/utility/hamburgerMenu.svelte
Normal 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>
|
||||
32
src/lib/components/utility/iconButton.svelte
Normal file
32
src/lib/components/utility/iconButton.svelte
Normal 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>
|
||||
48
src/lib/components/utility/navbar.svelte
Normal file
48
src/lib/components/utility/navbar.svelte
Normal 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>
|
||||
76
src/lib/components/utility/searchBar.svelte
Normal file
76
src/lib/components/utility/searchBar.svelte
Normal 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>
|
||||
30
src/lib/components/utility/toggle.svelte
Normal file
30
src/lib/components/utility/toggle.svelte
Normal 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>
|
||||
@@ -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
BIN
src/lib/server/db/users.db
Normal file
Binary file not shown.
70
src/lib/server/db/users.js
Normal file
70
src/lib/server/db/users.js
Normal 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
17
src/lib/services.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
src/lib/stores/alertStore.js
Normal file
3
src/lib/stores/alertStore.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export const newestAlert = writable([null, null])
|
||||
@@ -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)}`
|
||||
}
|
||||
}
|
||||
23
src/lib/utils/animations.js
Normal file
23
src/lib/utils/animations.js
Normal 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
71
src/lib/utils/utils.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,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})
|
||||
}
|
||||
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}
|
||||
<p>{songData.Name}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
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>
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user