Database overhall with knex.js, some things untested

This commit is contained in:
Eclypsed
2024-06-21 03:35:00 -04:00
parent ca80a6476f
commit 28c825b04b
40 changed files with 941 additions and 901 deletions

82
src/app.d.ts vendored
View File

@@ -18,6 +18,13 @@ declare global {
// Do not store data from other services in the database, only the data necessary to fetch whatever you need.
// This avoid syncronization issues. E.g. Store userId, and urlOrigin to fetch the user's name and profile picture.
// Note to self: POST vs PUT vs PATCH
// Use POST when a new resource is being created
// Use PUT when a resource is being replaced. Semantically, PUT means the entire replacement resource needs to be provided in the request
// Use PATCH when a resource is being changed or updated. Semantically, PATCH means only a partial resource needs to be provided in the request (The parts being updated/changed)
type ConnectionType = 'jellyfin' | 'youtube-music'
type User = {
id: string
username: string
@@ -59,57 +66,61 @@ declare global {
public readonly id: string
/** Retireves general information about the connection */
getConnectionInfo: () => Promise<ConnectionInfo>
getConnectionInfo(): Promise<ConnectionInfo>
/** Get's the user's recommendations from the corresponding service */
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]>
/**
* @param searchTerm The string of text to query
* @param filter Optional. A string of either 'song', 'album', 'artist', or 'playlist' to filter the kind of media items queried
* @returns A promise of an array of media items
* @param {string} searchTerm The string of text to query
* @param {'song' | 'album' | 'artist' | 'playlist'} filter Optional. A string of either 'song', 'album', 'artist', or 'playlist' to filter the kind of media items queried
* @returns {Promise<(Song | Album | Artist | Playlist)[]>} A promise of an array of media items
*/
search: <T extends 'song' | 'album' | 'artist' | 'playlist'>(searchTerm: string, filter?: T) => Promise<SearchFilterMap<T>[]>
search<T extends 'song' | 'album' | 'artist' | 'playlist'>(searchTerm: string, filter?: T): Promise<SearchFilterMap<T>[]>
/**
* @param id The id of the requested song
* @param headers The request headers sent by the Lazuli client that need to be relayed to the connection's request to the server (e.g. 'range').
* @returns A promise of response object containing the audio stream for the specified byte range
* @param {string} id The id of the requested song
* @param {Headers} headers The request headers sent by the Lazuli client that need to be relayed to the connection's request to the server (e.g. 'range').
* @returns {Promise<Response>} A promise of response object containing the audio stream for the specified byte range
*
* Fetches the audio stream for a song. Will return an response containing the audio stream if the fetch was successfull, otherwise throw an error.
*/
getAudioStream: (id: string, headers: Headers) => Promise<Response>
getAudioStream(id: string, headers: Headers): Promise<Response>
/**
* @param id The id of an album
* @returns A promise of the album as an Album object
* @param {string} id The id of an album
* @returns {Promise<Album>} A promise of the album as an Album object
*/
getAlbum: (id: string) => Promise<Album>
getAlbum(id: string): Promise<Album>
/**
* @param id The id of an album
* @returns A promise of the songs in the album as and array of Song objects
* @param {string} id The id of an album
* @returns {Promise<Song[]>} A promise of the songs in the album as and array of Song objects
*/
getAlbumItems: (id: string) => Promise<Song[]>
getAlbumItems(id: string): Promise<Song[]>
/**
* @param id The id of a playlist
* @returns A promise of the playlist of as a Playlist object
* @param {string} id The id of a playlist
* @returns {Promise<Playlist>} A promise of the playlist of as a Playlist object
*/
getPlaylist: (id: string) => Promise<Playlist>
getPlaylist(id: string): Promise<Playlist>
/**
* @param id The id of a playlist
* @param startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results
* @param limit The maximum number of playlist items to return
* @returns A promise of the songs in the playlist as and array of Song objects
* @param {string} id The id of a playlist
* @param {number} startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results
* @param {number} limit The maximum number of playlist items to return
* @returns {Promise<Song[]>} A promise of the songs in the playlist as and array of Song objects
*/
getPlaylistItems: (id: string, options?: { startIndex?: number, limit?: number }) => Promise<Song[]>
getPlaylistItems(id: string, options?: { startIndex?: number, limit?: number }): Promise<Song[]>
public readonly songs?: { // Optional because YouTube Music can't be asked to provide an actually useful API.
songs(ids: string[]): Promise<Song[]>
}
public readonly library: {
albums: () => Promise<Album[]>
artists: () => Promise<Artist[]>
playlists: () => Promise<Playlist[]>
albums(): Promise<Album[]>
artists(): Promise<Artist[]>
playlists(): Promise<Playlist[]>
}
}
@@ -123,7 +134,7 @@ declare global {
type Song = {
connection: {
id: string
type: 'jellyfin' | 'youtube-music'
type: ConnectionType
}
id: string
name: string
@@ -150,7 +161,7 @@ declare global {
type Album = {
connection: {
id: string
type: 'jellyfin' | 'youtube-music'
type: ConnectionType
}
id: string
name: string
@@ -167,7 +178,7 @@ declare global {
type Artist = {
connection: {
id: string
type: 'jellyfin' | 'youtube-music'
type: ConnectionType
}
id: string
name: string
@@ -178,7 +189,7 @@ declare global {
type Playlist = { // Keep Playlist items seperate from the playlist itself. What's really nice is playlist items can just be an ordered array of Songs
connection: {
id: string
type: 'jellyfin' | 'youtube-music'
type: ConnectionType
}
id: string
name: string
@@ -190,6 +201,15 @@ declare global {
}
}
type Mix = {
id: string
name: string
thumbnail?: string
description?: string
trackCount: number
duration: number
}
type HasDefinedProperty<T, K extends keyof T> = T & { [P in K]-?: Exclude<T[P], undefined> };
}

View File

@@ -1,5 +1,6 @@
import { redirect, type Handle, type HandleFetch, type RequestEvent } from '@sveltejs/kit'
import { redirect, type Handle, type RequestEvent } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY, SECRET_JWT_KEY } from '$env/static/private'
import { userExists, mixExists } from '$lib/server/api-helper'
import jwt from 'jsonwebtoken'
function verifyAuthToken(event: RequestEvent) {
@@ -14,18 +15,30 @@ function verifyAuthToken(event: RequestEvent) {
}
}
const unauthorizedResponse = new Response('Unauthorized.', { status: 401 })
const userNotFoundResponse = new Response('User not found.', { status: 404 })
const mixNotFoundResponse = new Response('Mix not found.', { status: 404 })
// * Custom Handle specifically for requests made to the API endpoint. Handles authorization and any other middleware verifications
const handleAPIRequest: Handle = async ({ event, resolve }) => {
const authorized = event.request.headers.get('apikey') === SECRET_INTERNAL_API_KEY || event.url.searchParams.get('apikey') === SECRET_INTERNAL_API_KEY || verifyAuthToken(event)
if (!authorized) unauthorizedResponse
const userId = event.params.userId
if (userId && !(await userExists(userId))) return userNotFoundResponse
const mixId = event.params.mixId
if (mixId && !(await mixExists(mixId))) return mixNotFoundResponse
return resolve(event)
}
export const handle: Handle = async ({ event, resolve }) => {
const urlpath = event.url.pathname
if (urlpath.startsWith('/login')) return resolve(event)
if (urlpath.startsWith('/api')) {
if (event.request.headers.get('apikey') === SECRET_INTERNAL_API_KEY || event.url.searchParams.get('apikey') === SECRET_INTERNAL_API_KEY || verifyAuthToken(event)) {
return resolve(event)
}
return new Response('Unauthorized', { status: 401 })
}
if (urlpath.startsWith('/api')) return handleAPIRequest({ event, resolve })
const authToken = event.cookies.get('lazuli-auth')
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
@@ -39,9 +52,3 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event)
}
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const authorized = verifyAuthToken(event)
return authorized ? fetch(request) : new Response('Unauthorized', { status: 401 })
}

View File

@@ -12,18 +12,16 @@
export let linked = true
</script>
<div class="break-keep">
<div class="break-words break-keep">
{#if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists === 'string'}
{mediaItem.artists}
{:else if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists !== 'string' && mediaItem.artists.length > 0}
{#each mediaItem.artists as artist, index}
{@const needsComma = index < mediaItem.artists.length - 1}
{#if linked}
<a class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
<a class:needsComma class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
{:else}
<span>{artist.name}</span>
{/if}
{#if index < mediaItem.artists.length - 1}
<span style="margin-left: -0.25em; margin-right: 0.25em">&#44;</span>
<span class:needsComma class="artist-name">{artist.name}</span>
{/if}
{/each}
{:else if 'uploader' in mediaItem && mediaItem.uploader}
@@ -40,3 +38,10 @@
{/if}
{/if}
</div>
<style>
.needsComma::after {
content: ',';
margin-right: 0.25em;
}
</style>

View File

@@ -22,11 +22,12 @@
let imageContainer: HTMLDivElement
// TODO: Implement auto-resizing
function updateImage(newThumbnailURL: string) {
if (!imageContainer) return
const width = imageContainer.clientWidth
const height = imageContainer.clientHeight
const width = imageContainer.clientWidth * 1.5 // 1.5x is a good compromise between sharpness and performance
const height = imageContainer.clientHeight * 1.5
const newImage = new Image(width, height)
imageContainer.appendChild(newImage)
@@ -57,8 +58,8 @@
}
newImage.onerror = () => {
removeOldImage()
newImage.style.opacity = '1'
console.error(`Image from url: ${newThumbnailURL} failed to update`)
imageContainer.removeChild(newImage)
}
}

View File

@@ -10,6 +10,10 @@
import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from './artistList.svelte'
// NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer.
// That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own
// dedicated sidebar like in spotify.
$: currentlyPlaying = $queue.current
let expanded = false
@@ -45,15 +49,15 @@
navigator.mediaSession.metadata = new MediaMetadata({
title: media.name,
artist: media.artists?.map((artist) => artist.name).join(', ') || media.uploader?.name,
artist: media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name,
album: media.album?.name,
artwork: [
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512', type: 'image/png' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512' },
],
})
}
@@ -103,7 +107,7 @@
{#if !expanded}
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10 pr-8">
<section class="flex w-80 gap-3">
<div class="relative h-full w-20 min-w-20">
<div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} />
</div>
<section class="flex flex-grow flex-col justify-center gap-1">
@@ -121,16 +125,14 @@
<IconButton on:click={() => $queue.previous()}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton>
<div class="aspect-square h-full rounded-full border border-neutral-700">
<IconButton on:click={() => (paused = !paused)}>
<div slot="icon">
{#if waiting}
<Loader size={1.5} />
{:else}
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
{/if}
</div>
</IconButton>
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
{#if waiting}
<Loader size={1.5} />
{:else}
<IconButton on:click={() => (paused = !paused)}>
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</IconButton>
{/if}
</div>
<IconButton on:click={() => $queue.clear()}>
<i slot="icon" class="fa-solid fa-stop text-xl" />
@@ -156,7 +158,10 @@
</div>
</section>
<section class="flex items-center justify-end gap-2.5 py-6 text-lg">
<div id="volume-slider" class="mx-4 flex h-10 w-44 flex-row-reverse items-center gap-3">
<div id="volume-slider" class="mx-4 flex h-10 w-44 items-center gap-3">
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</IconButton>
<Slider
bind:value={volume}
max={maxVolume}
@@ -164,9 +169,6 @@
if (volume > 0) localStorage.setItem('volume', volume.toString())
}}
/>
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</IconButton>
</div>
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
@@ -213,7 +215,7 @@
</section>
</section>
<section class="px-8">
<div id="progress-bar-expanded" class="mb-7">
<div id="progress-bar-expanded" class="mb-6">
<span bind:this={expandedCurrentTimeTimestamp} class="text-right" />
<Slider
bind:this={expandedProgressBar}
@@ -324,7 +326,7 @@
}
#expanded-player {
display: grid;
grid-template-rows: calc(100% - 12rem) 12rem;
grid-template-rows: calc(100% - 11rem) 11rem;
}
#song-queue-wrapper {
display: grid;

View File

@@ -1,23 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation'
export let icon: string
export let label: string
export let redirect: string
export let disabled: boolean
</script>
<button {disabled} class="block w-full py-2 text-left" on:click={() => goto(redirect)}>
<span class:disabled class="py-1 pl-8 {disabled ? 'text-lazuli-primary' : 'text-neutral-300'}">
<i class="{icon} mr-1.5 h-5 w-5" />
{label}
</span>
</button>
<style>
span.disabled {
border-left: 2px solid var(--lazuli-primary);
background: linear-gradient(to right, var(--lazuli-primary) -25%, transparent 25%);
}
</style>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { goto } from '$app/navigation'
export let color: string
export let name: string
export let id: string
export let disabled: boolean = false
</script>
<button style="--mix-color: {color};" {disabled} class="block w-full overflow-hidden text-ellipsis py-3 text-left" on:click={() => goto(`/mixes/${id}`)}>
<span class:disabled class="relative text-nowrap py-1 pl-6">{name}</span>
</button>
<style>
span:hover {
color: var(--mix-color);
}
span.disabled {
color: var(--mix-color);
}
span::before {
content: '•';
margin-right: 0.75rem;
font-weight: 900;
color: var(--mix-color);
}
span::after {
content: '';
width: 100%;
height: 0;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
background: linear-gradient(to right, var(--mix-color) 2px, color-mix(in srgb, var(--mix-color), transparent) 2px, transparent 20%);
transition: height 100ms linear;
}
span.disabled::after {
height: 100%;
}
</style>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { goto } from '$app/navigation'
export let icon: string
export let label: string
export let redirect: string
export let disabled: boolean = false
</script>
<button {disabled} class="block w-full overflow-hidden text-ellipsis py-2 text-left" on:click={() => goto(redirect)}>
<span class:disabled class="relative text-nowrap py-1 pl-6 text-neutral-300">
<i class="{icon} mr-1.5 h-5 w-5" />
{label}
</span>
</button>
<style>
span:hover {
color: var(--lazuli-primary);
}
span.disabled {
color: var(--lazuli-primary);
}
span::before {
content: '';
width: 100%;
height: 0;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
background: linear-gradient(to right, var(--lazuli-primary) 2px, color-mix(in srgb, var(--lazuli-primary), transparent) 2px, transparent 20%);
transition: height 100ms linear;
}
span.disabled:before {
height: 100%;
}
</style>

View File

@@ -26,7 +26,7 @@
<div
id="slider-track"
class="relative isolate h-1.5 w-full rounded-full bg-neutral-600"
class="relative isolate h-1 w-full rounded bg-neutral-600"
style="--slider-color: var(--lazuli-primary)"
role="slider"
tabindex="0"
@@ -39,7 +39,7 @@
on:input={(event) => dispatch('seeking', { value: event.currentTarget.value })}
on:change={(event) => dispatch('seeked', { value: event.currentTarget.value })}
type="range"
class="absolute z-10 h-1.5 w-full"
class="absolute z-10 h-1 w-full"
step="any"
min="0"
{max}
@@ -48,7 +48,7 @@
aria-hidden="true"
aria-disabled="true"
/>
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1.5 rounded-full bg-white transition-colors" />
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1 rounded-full bg-white transition-colors" />
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" />
</div>

View File

@@ -0,0 +1,48 @@
import { DB, type DBSchemas } from './db'
import { Jellyfin } from './jellyfin'
import { YouTubeMusic } from './youtube-music'
export async function userExists(userId: string): Promise<boolean> {
return Boolean(await DB.users.where('id', userId).first(DB.knex.raw('EXISTS(SELECT 1)')))
}
export async function mixExists(mixId: string): Promise<Boolean> {
return Boolean(await DB.mixes.where('id', mixId).first(DB.knex.raw('EXISTS(SELECT 1)')))
}
function connectionBuilder(schema: DBSchemas.Connections): Connection {
const { id, userId, type, serviceUserId, accessToken } = schema
switch (type) {
case 'jellyfin':
return new Jellyfin(id, userId, serviceUserId, schema.serverUrl, accessToken)
case 'youtube-music':
return new YouTubeMusic(id, userId, serviceUserId, accessToken, schema.refreshToken, schema.expiry)
}
}
/**
* Queries the database for a specific connection.
*
* @param id The id of the connection
* @returns An instance of a Connection
* @throws ReferenceError if there is no connection with an id matches the one passed
*/
export async function buildConnection(id: string): Promise<Connection> {
const schema = await DB.connections.where('id', id).first()
if (!schema) throw ReferenceError(`Connection of Id ${id} does not exist`)
return connectionBuilder(schema)
}
/**
* Queries the database for all connections belong to a user of the specified id.
*
* @param userId The id of a user
* @returns An array of connection instances for each of the user's connections
* @throws ReferenceError if there is no user with an id matches the one passed
*/
export async function buildUserConnections(userId: string): Promise<Connection[]> {
if (!(await userExists(userId))) throw ReferenceError(`User of Id ${userId} does not exist`)
return (await DB.connections.where('userId', userId).select('*')).map(connectionBuilder)
}

View File

@@ -1,27 +0,0 @@
import { DB } from './db'
import { Jellyfin } from './jellyfin'
import { YouTubeMusic } from './youtube-music'
const constructConnection = (connectionInfo: ReturnType<typeof DB.getConnectionInfo>): Connection | undefined => {
if (!connectionInfo) return undefined
const { id, userId, type, service, tokens } = connectionInfo
switch (type) {
case 'jellyfin':
return new Jellyfin(id, userId, service.userId, service.serverUrl, tokens.accessToken)
case 'youtube-music':
return new YouTubeMusic(id, userId, service.userId, tokens.accessToken, tokens.refreshToken, tokens.expiry)
}
}
function getConnection(id: string): Connection | undefined {
return constructConnection(DB.getConnectionInfo(id))
}
const getUserConnections = (userId: string): Connection[] | undefined => {
return DB.getUserConnectionInfo(userId)?.map((info) => constructConnection(info)!)
}
export const Connections = {
getConnection,
getUserConnections,
}

View File

@@ -1,132 +1,187 @@
import Database from 'better-sqlite3'
import type { Database as Sqlite3DB } from 'better-sqlite3'
import { generateUUID } from '$lib/utils'
import knex from 'knex'
import { SqliteError } from 'better-sqlite3'
interface DBConnectionsTableSchema {
id: string
userId: string
type: string
service?: string
tokens?: string
}
const connectionTypes = ['jellyfin', 'youtube-music']
export type ConnectionRow = {
id: string
userId: string
} & (
| {
type: 'jellyfin'
service: {
userId: string
serverUrl: string
}
tokens: {
accessToken: string
}
}
| {
type: 'youtube-music'
service: {
userId: string
}
tokens: {
accessToken: string
refreshToken: string
expiry: number
}
}
)
class Storage {
private readonly database: Sqlite3DB
constructor(database: Sqlite3DB) {
this.database = database
this.database.pragma('foreign_keys = ON')
this.database.exec(`CREATE TABLE IF NOT EXISTS Users(
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(30) UNIQUE NOT NULL,
passwordHash VARCHAR(72) NOT NULL
)`)
this.database.exec(`CREATE TABLE IF NOT EXISTS Connections(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
type VARCHAR(36) NOT NULL,
service TEXT,
tokens TEXT,
FOREIGN KEY(userId) REFERENCES Users(id)
)`)
this.database.exec(`CREATE TABLE IF NOT EXISTS Playlists(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
name TEXT NOT NULL,
description TEXT,
items TEXT,
FOREIGN KEY(userId) REFERENCES Users(id)
)`)
export declare namespace DBSchemas {
interface Users {
id: string
username: string
passwordHash: string
}
public getUser = (id: string): User | undefined => {
const user = this.database.prepare(`SELECT * FROM Users WHERE id = ? LIMIT 1`).get(id) as User | undefined
return user
interface JellyfinConnection {
id: string
userId: string
type: 'jellyfin'
serviceUserId: string
serverUrl: string
accessToken: string
}
public getUsername = (username: string): User | undefined => {
const user = this.database.prepare(`SELECT * FROM Users WHERE lower(username) = ? LIMIT 1`).get(username.toLowerCase()) as User | undefined
return user
interface YouTubeMusicConnection {
id: string
userId: string
type: 'youtube-music'
serviceUserId: string
accessToken: string
refreshToken: string
expiry: number
}
public addUser = (username: string, passwordHash: string): User => {
const userId = generateUUID()
this.database.prepare(`INSERT INTO Users(id, username, passwordHash) VALUES(?, ?, ?)`).run(userId, username, passwordHash)
return this.getUser(userId)!
type Connections = JellyfinConnection | YouTubeMusicConnection
interface Mixes {
id: string
userId: string
name: string
thumbnailTag?: string
description?: string
trackCount: number
duration: number
}
public deleteUser = (id: string): void => {
const commandInfo = this.database.prepare(`DELETE FROM Users WHERE id = ?`).run(id)
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
interface MixItems {
mixId: string
connectionId: string
connectionType: ConnectionType
id: string
index: number
}
public getConnectionInfo = (id: string): ConnectionRow | undefined => {
const result = this.database.prepare(`SELECT * FROM Connections WHERE id = ? LIMIT 1`).get(id) as DBConnectionsTableSchema | undefined
if (!result) return undefined
const { userId, type, service, tokens } = result
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
return { id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens }
}
public getUserConnectionInfo = (userId: string): ConnectionRow[] | undefined => {
const user = this.getUser(userId)
if (!user) return undefined
const connectionRows = this.database.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as DBConnectionsTableSchema[]
const connections: ConnectionRow[] = []
for (const { id, type, service, tokens } of connectionRows) {
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
connections.push({ id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens })
interface Songs {
connectionId: string
connectionType: ConnectionType
id: string
name: string
duration: number
thumbnailUrl: string
releaseDate?: string
artists?: {
id: string
name: string
}[]
album?: {
id: string
name: string
}
return connections
}
public addConnectionInfo = (connectionInfo: Omit<ConnectionRow, 'id'>): string => {
const { userId, type, service, tokens } = connectionInfo
const connectionId = generateUUID()
this.database.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens))
return connectionId
}
public deleteConnectionInfo = (id: string): void => {
const commandInfo = this.database.prepare(`DELETE FROM Connections WHERE id = ?`).run(id)
if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
}
public updateTokens = (id: string, tokens: ConnectionRow['tokens']): void => {
const commandInfo = this.database.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(JSON.stringify(tokens), id)
if (commandInfo.changes === 0) throw new Error('Failed to update tokens')
uploader?: {
id: string
name: string
}
isVideo: boolean
}
}
export const DB = new Storage(new Database('./src/lib/server/lazuli.db', { verbose: console.info }))
class Database {
public readonly knex: knex.Knex
constructor(knex: knex.Knex<'better-sqlite3'>) {
this.knex = knex
}
public uuid() {
return this.knex.fn.uuid()
}
public get users() {
return this.knex<DBSchemas.Users>('Users')
}
public get connections() {
return this.knex<DBSchemas.Connections>('Connections')
}
public get mixes() {
return this.knex<DBSchemas.Mixes>('Mixes')
}
public get mixItems() {
return this.knex<DBSchemas.MixItems>('MixItems')
}
public get songs() {
return this.knex<DBSchemas.Songs>('Songs')
}
public get sqliteError() {
return SqliteError
}
public static async createUsersTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Users')
if (exists) return
await db.schema.createTable('Users', (tb) => {
tb.uuid('id').primary(), tb.string('username').unique().notNullable().checkLength('<=', 30), tb.string('passwordHash').notNullable().checkLength('=', 60)
})
}
public static async createConnectionsTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Connections')
if (exists) return
await db.schema.createTable('Connections', (tb) => {
tb.uuid('id').primary(),
tb.uuid('userId').notNullable().references('id').inTable('Users'),
tb.enum('type', connectionTypes).notNullable(),
tb.string('serviceUserId'),
tb.string('serverUrl'),
tb.string('accessToken'),
tb.string('refreshToken'),
tb.integer('expiry')
})
}
public static async createMixesTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Mixes')
if (exists) return
await db.schema.createTable('Mixes', (tb) => {
tb.uuid('id').primary(),
tb.uuid('userId').notNullable().references('id').inTable('Users'),
tb.string('name').notNullable(),
tb.uuid('thumbnailTag'),
tb.string('description'),
tb.integer('trackCount').notNullable(),
tb.integer('duration').notNullable()
})
}
public static async createMixItemsTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('MixItems')
if (exists) return
await db.schema.createTable('MixItems', (tb) => {
tb.uuid('mixId').notNullable().references('id').inTable('Mixes'),
tb.uuid('connectionId').notNullable().references('id').inTable('Connections'),
tb.enum('connectionType', connectionTypes).notNullable(),
tb.string('id').notNullable()
tb.integer('index').notNullable()
})
}
public static async createSongsTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Songs')
if (exists) return
await db.schema.createTable('Songs', (tb) => {
tb.uuid('connectionId').notNullable().references('id').inTable('Connections'),
tb.enum('connectionType', connectionTypes),
tb.string('id').notNullable(),
tb.string('name').notNullable(),
tb.integer('duration').notNullable(),
tb.string('thumbnailUrl').notNullable(),
tb.datetime('releaseDate', { precision: 3 }),
tb.json('artists'),
tb.json('album'),
tb.json('uploader'),
tb.boolean('isVideo').notNullable()
})
}
}
const db = knex<'better-sqlite3'>({ client: 'better-sqlite3', connection: { filename: './src/lib/server/lazuli.db' }, useNullAsDefault: false })
await Promise.all([Database.createUsersTable(db), Database.createConnectionsTable(db), Database.createMixesTable(db), Database.createMixItemsTable(db), Database.createSongsTable(db)])
export const DB = new Database(db)

View File

@@ -1,77 +0,0 @@
import { PUBLIC_VERSION } from '$env/static/public'
import { MusicBrainzApi } from 'musicbrainz-api'
const mbApi = new MusicBrainzApi({
appName: 'Lazuli',
appVersion: PUBLIC_VERSION,
appContactInfo: 'Ec1ypsed@proton.me',
})
async function potentialAliasesFromNames(artistNames: string[]) {
const luceneQuery = artistNames.join(' OR ')
const artistsResponse = await mbApi.search('artist', { query: luceneQuery })
const SCORE_THRESHOLD = 90
const possibleArtists = artistsResponse.artists.filter((artist) => artist.score >= SCORE_THRESHOLD)
const aliases = possibleArtists.flatMap((artist) => [artist.name].concat(artist.aliases?.filter((alias) => alias.primary !== null).map((alias) => alias.name) ?? []))
return [...new Set(aliases)] // Removes any duplicates
}
export class MusicBrainz {
static async searchRecording(songName: string, artistNames?: string[]) {
const standardSearchResults = await mbApi.search('recording', { query: songName, limit: 5 })
const SCORE_THRESHOLD = 90
const bestResults = standardSearchResults.recordings.filter((recording) => recording.score >= SCORE_THRESHOLD)
const artistAliases = artistNames ? await potentialAliasesFromNames(artistNames) : null
const luceneQuery = artistAliases ? `"${songName}"`.concat(` AND (${artistAliases.map((alias) => `artist:"${alias}"`).join(' OR ')})`) : `"${songName}"`
console.log(luceneQuery)
const searchResults = await mbApi.search('recording', { query: luceneQuery, limit: 1 })
if (searchResults.recordings.length === 0) {
console.log('Nothing returned for ' + songName)
return null
}
const topResult = searchResults.recordings[0]
// const bestMatch = searchResults.recordings.reduce((prev, current) => (prev.score > current.score ? prev : current))
console.log(JSON.stringify(topResult))
}
static async searchRelease(albumName: string, artistNames?: string[]): Promise<MusicBrainz.ReleaseSearchResult | null> {
const searchResulst = await mbApi.search('release', { query: albumName, limit: 10 })
if (searchResulst.releases.length === 0) {
console.log(JSON.stringify('Nothing returned for ' + albumName))
return null
}
const bestMatch = searchResulst.releases.reduce((prev, current) => {
if (prev.score === current.score) return new Date(prev.date).getTime() > new Date(current.date).getTime() ? prev : current
return prev.score > current.score ? prev : current
})
const { id, title, date } = bestMatch
const trackCount = bestMatch.media.reduce((acummulator, current) => acummulator + current['track-count'], 0)
const artists = bestMatch['artist-credit']?.map((artist) => ({ id: artist.artist.id, name: artist.artist.name }))
return { id, name: title, releaseDate: date, artists, trackCount } satisfies MusicBrainz.ReleaseSearchResult
}
static async searchArtist(artistName: string) {
const searchResults = await mbApi.search('artist', { query: artistName })
}
}
declare namespace MusicBrainz {
type ReleaseSearchResult = {
id: string
name: string
releaseDate: string
artists?: {
id: string
name: string
}[]
trackCount: number
}
}

View File

@@ -1,10 +1,10 @@
import { youtube, type youtube_v3 } from 'googleapis/build/src/apis/youtube'
import { DB } from './db'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
import type { InnerTube } from './youtube-music-types'
import { DB } from './db'
const ytDataApi = youtube('v3')
const ytDataApi = youtube('v3') // TODO: At some point I want to ditch this package and just make the API calls directly. Fewer dependecies
type ytMusicv1ApiRequestParams =
| {
@@ -55,7 +55,10 @@ export class YouTubeMusic implements Connection {
}
public async getConnectionInfo() {
const access_token = await this.requestManager.accessToken.catch(() => null)
const access_token = await this.requestManager.accessToken.catch(() => {
console.log('Failed to get yt access token')
return null
})
let username: string | undefined, profilePicture: string | undefined
if (access_token) {
@@ -515,6 +518,43 @@ export class YouTubeMusic implements Connection {
}
}) as ScrapedMediaItemMap<T[number]>[]
}
// ! HOLY FUCK HOLY FUCK THIS IS IT!!!! THIS IS HOW YOU CAN BATCH FETCH FULL DETAILS FOR COMPLETELY UNRELATED SONGS IN ONE API CALL!!!!
// ! IT GIVES BACK FUCKING EVERYTHING (almost)! NAME, ALBUM, ARTISTS, UPLOADER, DURATION, THUMBNAIL.
// ! The only thing kinda missing is release date, but that could be fetched from the official API. In fact I'll already need to make a call to
// ! the offical API to get the thumbnails for the videos any way. And since you can batch call that one, you won't be making any extra queries just
// ! to get the release date. HOLY FUCK THIS IS PERFECT! (something is going to go wrong in the future for sure)
private async testMethod(videoIds: string[]) {
const currentDate = new Date()
const year = currentDate.getUTCFullYear().toString()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0')
const response = await fetch('https://music.youtube.com/youtubei/v1/music/get_queue', {
headers: {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
authorization: `Bearer ${await this.requestManager.accessToken}`,
},
method: 'POST',
body: JSON.stringify({
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${year + month + day}.01.00`,
},
},
videoIds,
}),
})
if (!response.ok) {
console.log(response)
return
}
const data = await response.json()
console.log(JSON.stringify(data))
}
}
function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer): InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist {
@@ -742,8 +782,8 @@ class YTRequestManager {
if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
this.accessTokenRefreshRequest = refreshAccessToken()
.then(({ accessToken, expiry }) => {
DB.updateTokens(this.connectionId, { accessToken, refreshToken: this.refreshToken, expiry })
.then(async ({ accessToken, expiry }) => {
await DB.connections.where('id', this.connectionId).update('tokens', { accessToken, refreshToken: this.refreshToken, expiry })
this.currentAccessToken = accessToken
this.expiry = expiry
this.accessTokenRefreshRequest = null

View File

@@ -1,14 +1,32 @@
<script lang="ts">
import SearchBar from '$lib/components/util/searchBar.svelte'
import type { LayoutData } from './$types'
import NavTab from '$lib/components/navbar/navTab.svelte'
import NavTab from '$lib/components/util/navTab.svelte'
import MixTab from '$lib/components/util/mixTab.svelte'
import MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
import { goto } from '$app/navigation'
import IconButton from '$lib/components/util/iconButton.svelte'
export let data: LayoutData
let mixData = [
{
name: 'J-Core Mix',
color: 'red',
id: 'SomeId',
},
{
name: 'Best of: 葉月ゆら',
color: 'purple',
id: 'SomeId',
},
]
$: currentPathname = data.url.pathname
let newMixNameInputOpen = false
// I'm thinking I might want to make /albums, /artists, and /playlists all there own routes and just wrap them in a (library) layout
</script>
<main id="grid-wrapper" class="h-full">
@@ -24,10 +42,23 @@
</IconButton>
</div>
</nav>
<section id="sidebar" class="pt-4 font-light">
<NavTab label={'Home'} icon={'fa-solid fa-wave-square'} redirect={'/'} disabled={currentPathname === '/'} />
<NavTab label={'Playlists'} icon={'fa-solid fa-bars-staggered'} redirect={'/playlists'} disabled={/^\/playlists.*$/.test(currentPathname)} />
<NavTab label={'Library'} icon={'fa-solid fa-book'} redirect={'/library'} disabled={/^\/library.*$/.test(currentPathname)} />
<section id="sidebar" class="relative pt-4 text-sm font-normal">
<div class="mb-10">
<NavTab label="Home" icon="fa-solid fa-wave-square" redirect="/" disabled={currentPathname === '/'} />
<NavTab label="Playlists" icon="fa-solid fa-bars-staggered" redirect="/playlists" disabled={/^\/playlists.*$/.test(currentPathname)} />
<NavTab label="Library" icon="fa-solid fa-book" redirect="/library" disabled={/^\/library.*$/.test(currentPathname)} />
</div>
<h1 class="mb-1 flex h-5 items-center justify-between pl-6 text-sm text-neutral-400">
Your Mixes
<IconButton halo={true} on:click={() => (mixData = [{ name: 'New Mix', color: 'grey', id: 'SomeId' }, ...mixData])}>
<i slot="icon" class="fa-solid fa-plus" />
</IconButton>
</h1>
<div>
{#each mixData as mix}
<MixTab {...mix} />
{/each}
</div>
</section>
<section id="content-wrapper" class="no-scrollbar overflow-x-clip overflow-y-scroll pr-8">
<slot />
@@ -39,8 +70,8 @@
#grid-wrapper,
#navbar {
display: grid;
column-gap: 1rem;
grid-template-columns: 14rem auto 14rem;
column-gap: 3rem;
grid-template-columns: 12rem auto 12rem;
}
#grid-wrapper {
@@ -54,7 +85,4 @@
#sidebar {
grid-area: 2 / 1 / 3 / 2;
}
#content-wrapper {
grid-area: 2 / 2 / 3 / 4;
}
</style>

View File

@@ -10,7 +10,7 @@
$: currentPathname = data.url.pathname
</script>
<main class="py-8">
<main class="py-4">
<nav id="nav-options" class="mb-8 flex h-12 justify-between">
<section class="relative flex h-full gap-4">
<button disabled={/^\/library$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library')}>History</button>

View File

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

View File

@@ -1 +1,18 @@
<h1>This would be a good place for listen history</h1>
<script lang="ts">
import type { PageServerData } from './$types.js'
export let data: PageServerData
async function testRequest() {
const mixData = await fetch(`/api/v1/users/${data.user.id}fkaskdkja/mixes`, {
credentials: 'include',
}).then((response) => response.json())
console.log(mixData)
}
</script>
<div>
<h1>This would be a good place for listen history</h1>
<button on:click={testRequest} class="h-14 w-20 rounded-lg bg-lazuli-primary">Test Request</button>
</div>

View File

@@ -16,12 +16,14 @@
<h1>{albums.error}</h1>
{:else if $itemDisplayState === 'list'}
<div class="text-md flex flex-col gap-4">
{#each albums as album}
<!-- .slice is temporary to mimic performance with pagination -->
{#each albums.slice(0, 100) as album}
<ListItem mediaItem={album} />
{/each}
</div>
{:else}
<div id="library-wrapper">
<!-- .slice is temporary to mimic performance with pagination -->
{#each albums as album}
<AlbumCard {album} />
{/each}
@@ -36,4 +38,10 @@
/* gap: 1.5rem; */
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
}
/* This caps the maxiumn number of columns at 10. Beyond that point the cards will continuously get larger */
@media (min-width: calc(13rem * 10)) {
#library-wrapper {
grid-template-columns: repeat(10, 1fr);
}
}
</style>

View File

@@ -2,13 +2,12 @@
import LazyImage from '$lib/components/media/lazyImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte'
import ArtistList from '$lib/components/media/artistList.svelte'
import Services from '$lib/services.json'
import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores'
export let album: Album
const queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly
async function playAlbum() {
const itemsResponse = await fetch(`/api/connections/${album.connection.id}/album/${album.id}/items`, {
credentials: 'include',
@@ -20,20 +19,21 @@
}
const data = (await itemsResponse.json()) as { items: Song[] }
queueRef.setQueue(data.items)
$queue.setQueue(data.items)
}
</script>
<div class="p-3">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded-lg">
<div class="overflow-hidden p-3">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}>
<LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} />
</button>
<div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0">
<div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity">
<IconButton halo={true} on:click={playAlbum}>
<i slot="icon" class="fa-solid fa-play text-2xl" />
</IconButton>
</div>
<img id="connection-type-icon" class="absolute left-2 top-2 h-9 w-9 opacity-0 transition-opacity" src={Services[album.connection.type].icon} alt={Services[album.connection.type].displayName} />
</div>
<div class="py-2 text-center text-sm">
<div class="line-clamp-2">{album.name}</div>
@@ -45,15 +45,18 @@
<style>
#thumbnail-wrapper:hover > #thumbnail {
filter: brightness(40%);
filter: brightness(35%);
}
#thumbnail-wrapper:hover > #play-button {
opacity: 100%;
opacity: 1;
}
/* #connection-type-icon {
filter: grayscale();
} */
#thumbnail-wrapper:hover > #connection-type-icon {
opacity: 1;
}
#thumbnail {
transition: filter 150ms ease;
}
#play-button {
transition: opacity 150ms ease;
}
</style>

View File

@@ -27,7 +27,11 @@ export const actions: Actions = {
if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message })
const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, serverUrl: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } })
const userId = locals.user.id
const serviceUserId = authData.User.Id
const accessToken = authData.AccessToken
const newConnectionId = await DB.connections.insert({ id: DB.uuid(), userId, type: 'jellyfin', serviceUserId, serverUrl: serverUrl.toString(), accessToken }, 'id').then((data) => data[0].id)
const newConnection = await fetch(`/api/connections?id=${newConnectionId}`)
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>)
@@ -39,18 +43,18 @@ export const actions: Actions = {
const formData = await request.formData()
const { code } = Object.fromEntries(formData)
const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // ! DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD.
const { tokens } = await client.getToken(code.toString())
const { access_token, refresh_token, expiry_date } = (await client.getToken(code.toString())).tokens
const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! })
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: access_token! })
const userChannel = userChannelResponse.data.items![0]
const newConnectionId = DB.addConnectionInfo({
userId: locals.user.id,
type: 'youtube-music',
service: { userId: userChannel.id! },
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
})
const userId = locals.user.id
const serviceUserId = userChannel.id!
const newConnectionId = await DB.connections
.insert({ id: DB.uuid(), userId, type: 'youtube-music', serviceUserId, accessToken: access_token!, refreshToken: refresh_token!, expiry: expiry_date! }, 'id')
.then((data) => data[0].id)
const newConnection = await fetch(`/api/connections?id=${newConnectionId}`)
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>)
@@ -62,7 +66,7 @@ export const actions: Actions = {
const formData = await request.formData()
const connectionId = formData.get('connectionId')!.toString()
DB.deleteConnectionInfo(connectionId)
await DB.connections.where('id', connectionId).del()
return { deletedConnectionId: connectionId }
},

View File

@@ -1,12 +1,12 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url, request }) => {
const connectionId = url.searchParams.get('connection')
const id = url.searchParams.get('id')
if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
// Might want to re-evaluate how specific I make these ^ v error response messages
const connection = Connections.getConnection(connectionId)
const connection = await buildConnection(connectionId).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' })

View File

@@ -1,22 +1,19 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url }) => {
const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
if (!ids) return new Response('Missing id query parameter', { status: 400 })
const connections = (
await Promise.all(
ids.map((id) =>
Connections.getConnection(id)
?.getConnectionInfo()
.catch((reason) => {
console.error(`Failed to fetch connection info: ${reason}`)
return undefined
}),
),
)
).filter((connection): connection is ConnectionInfo => connection?.id !== undefined)
const connections = (await Promise.all(ids.map((id) => buildConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null)
return Response.json({ connections })
const getConnectionInfo = (connection: Connection) =>
connection.getConnectionInfo().catch((reason) => {
console.error(`Failed to fetch connection info: ${reason}`)
return null
})
const connectionInfo = (await Promise.all(connections.map(getConnectionInfo))).filter((connectionInfo): connectionInfo is ConnectionInfo => connectionInfo !== null)
return Response.json({ connections: connectionInfo })
}

View File

@@ -1,9 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => {
const connectionId = params.connectionId!
const connection = Connections.getConnection(connectionId)
const connection = await buildConnection(connectionId).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const albumId = url.searchParams.get('id')

View File

@@ -1,12 +1,13 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => {
const { connectionId, albumId } = params
const connection = Connections.getConnection(connectionId!)
const connection = await buildConnection(connectionId!).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const items = await connection.getAlbumItems(albumId!).catch(() => undefined)
const items = await connection.getAlbumItems(albumId!).catch(() => null)
if (!items) return new Response(`Failed to fetch album with id: ${albumId!}`, { status: 400 })
return Response.json({ items })

View File

@@ -1,9 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => {
const connectionId = params.connectionId!
const connection = Connections.getConnection(connectionId)
const connection = await buildConnection(connectionId).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const playlistId = url.searchParams.get('id')

View File

@@ -1,9 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => {
const { connectionId, playlistId } = params
const connection = Connections.getConnection(connectionId!)
const connection = await buildConnection(connectionId!).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 })
const startIndexString = url.searchParams.get('startIndex')

View File

@@ -5,6 +5,56 @@ const MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE = 16383
// TODO: It is possible to get images through many paths in the jellyfin API. To add support for a path, add a regex for it
const jellyfinImagePathnames = [/^\/Items\/([0-9a-f]{32})\/Images\/(Primary|Art|Backdrop|Banner|Logo|Thumb|Disc|Box|Screenshot|Menu|Chapter|BoxRear|Profile)$/]
// * Notes for the future:
// Spotify does not appear to use query parameter to scale its iamges and instead scale them via slight variations in the URL:
//
// Demon's Jingles - 300x300 - https://i.scdn.co/image/ab67616d00001e0230d02cfb02d41c65f0259c49
// Red Heart - 300x300 - https://i.scdn.co/image/ab67616d00001e02c1b377b71713519ac06d8025
// Demon's Jingles - 64x64 - https://i.scdn.co/image/ab67616d0000485130d02cfb02d41c65f0259c49
// Red Heart - 64x64 - https://i.scdn.co/image/ab67616d00004851c1b377b71713519ac06d8025
//
// From what I can tell the first 7 of the 40 hex characters are always the same. The next five seem to be based on what kind of media
// the image is asscoiated with.
//
// Type | Song | Artist |
// Code | d0000 | 10000 |
//
// Then there are four more characters which appear to be a size code. However size codes do no appear work across different media types.
//
// Size | 64x64 | 160x160 | 300x300 | 320x320 | 640x640 | 640x640 |
// Code | 4851 | f178 | 1e02 | 5174 | b273 | e5eb |
// Type | Song | Artist | Song | Artist | Song | Artist |
//
// It's also worth noting that while I have been using the word 'Song' spotify doesn't actually appear to have unique images for songs and
// from what I can tell all Song images are actually just the image of the album the song is from. In the case of singles, those are really
// just 1-length albums as far as Spotify is concerned. So consider Songs and Albums the same when it comes to Spotify images
//
// Playlists are pretty interesting, here's a sample of a playlist image:
//
// J-Core Mix - 60x60 - https://mosaic.scdn.co/60/ab67616d00001e021ad3e724a80ccbd585df8ea6ab67616d00001e029056aaf4675ec39d04b38c6dab67616d00001e02b204764ed7641264c954afa4ab67616d00001e02c1b377b71713519ac06d8025
//
// There appear to be three sizes of playlist thumbnail as well, 60x60, 300x300, and 640x640. However this time the dimension is embeded directly pathname.
// The much longer hex code in the pathname is actually just the four codes for the thumbnails that show up in the mosaic concatenated together using the 300x300 code for each.
// What's even cooler is this endpoint can generate them on the fly, meaning you just stick four hex strings in and it will generate the mosaic.
// You have to put in four though, two, three, and anything greater than four will simply return a bad request, and one will simply return the image you specified
// but at the size specfied in the image code. You can however use whatever image size code in the hex strings though and it will generate the mosaic using whaterver
// resolution passed, it just doesn't make any sense to use the 640x640 code since the grid is a 2x2 with a maximum resolution of 640x640 anyway, so just use 300x300.
//
// The only question I have left is, why? Between YouTube and Spotify I really question the API design of some of these multi-billion dollar companies.
// InnerTube API response are just abominable, who the fuck describes the structure of their UI by wrapping the actually useful data in layers of completely
// abstract objcts and arrays like it's fucking HTML. I should never have to traverse 20+ layers deep into nonsense objects like musicResponsiveListItemFlexColumnRenderer
// just to get a name. At least Spotify has a well designed and developer friendly API structure, but seriously, why do all of the size code nonsense. If you're not
// going to support formats like webm and only want to stick to static images, that's fine, but just make the path /image/{itemId} and then you can
// specify what size you need with a query parameter ?size=small|medium|large. That way if you ever do want to move to a model that can support dynamically generating images
// of a specific size with query params, your API is already partially the way there. I won't complain about the playlist image generator though, that's pretty cool.
// My only suggestions would be to get rid of the image code nonsense and just use the song/album ids and also make both the ids and size a query param, not part of the path.
// YouTube Music does support dynamically resizing images in the API which is nice, except for the fact that they do it in the stupidest fucking way I have ever seen.
// What the fuck is this: =w1000&h1000. Those are not what query params look like, why would you bother making this fake query param bullshit when what you are trying to do has
// been a standard part URLs since their inception. Also you pull your images from SIX DIFFERENT FUCKING ORIGINS, only four of which actually support image scaling.
// In both YouTube Music and Spotify none of these image endpoints are protected in any way, so why do you inist on pissing off me and probably your own developers with these asinine practices?
//
// It's not perfect, but compared to this bullshit, the Jellyfin API is really fucking good.
function modifyImageURL(imageURL: URL, options?: { maxWidth?: number; maxHeight?: number }): string | null {
const maxWidth = options?.maxWidth
const maxHeight = options?.maxHeight

View File

@@ -1,28 +1,23 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url }) => {
const { query, userId, filter } = Object.fromEntries(url.searchParams) as { [k: string]: string | undefined }
if (!(query && userId)) return new Response('Missing search parameter', { status: 400 })
const userConnections = Connections.getUserConnections(userId)
const userConnections = await buildUserConnections(userId).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 })
let checkedFilter: 'song' | 'album' | 'artist' | 'playlist' | undefined
if (filter === 'song' || filter === 'album' || filter === 'artist' || filter === 'playlist') checkedFilter = filter
const searchResults = (
await Promise.all(
userConnections.map((connection) =>
connection.search(query, checkedFilter).catch((reason) => {
console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)
return undefined
}),
),
)
)
.flat()
.filter((result): result is Song | Album | Artist | Playlist => result?.id !== undefined)
const search = (connection: Connection) =>
connection.search(query, checkedFilter).catch((reason) => {
console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)
return null
})
const searchResults = (await Promise.all(userConnections.map(search))).flat().filter((result): result is Song | Album | Artist | Playlist => result !== null)
return Response.json({ searchResults })
}

View File

@@ -1,22 +1,17 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId!
const userConnections = Connections.getUserConnections(userId)
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 })
const connections = (
await Promise.all(
userConnections.map((connection) =>
connection.getConnectionInfo().catch((reason) => {
console.log(`Failed to fetch connection info: ${reason}`)
return undefined
}),
),
)
).filter((info): info is ConnectionInfo => info !== undefined)
const getConnectionInfo = (connection: Connection) =>
connection.getConnectionInfo().catch((reason) => {
console.log(`Failed to fetch connection info: ${reason}`)
return null
})
const connections = (await Promise.all(userConnections.map(getConnectionInfo))).filter((info): info is ConnectionInfo => info !== null)
return Response.json({ connections })
}

View File

@@ -1,10 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId!
const userConnections = Connections.getUserConnections(userId)
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 })
const items = (await Promise.all(userConnections.map((connection) => connection.library.albums()))).flat()

View File

@@ -1,10 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId!
const userConnections = Connections.getUserConnections(userId)
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 })
const items = (await Promise.all(userConnections.map((connection) => connection.library.artists()))).flat()

View File

@@ -1,10 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId!
const userConnections = Connections.getUserConnections(userId)
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 })
const items = (await Promise.all(userConnections.map((connection) => connection.library.playlists()))).flat()

View File

@@ -1,26 +1,19 @@
import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections'
import { buildUserConnections } from '$lib/server/api-helper'
// This is temporary functionally for the sake of developing the app.
// In the future will implement more robust algorithm for offering recommendations
export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId!
const userConnections = Connections.getUserConnections(userId)
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 })
const recommendations = (
await Promise.all(
userConnections.map((connection) =>
connection.getRecommendations().catch((reason) => {
console.log(`Failed to fetch recommendations: ${reason}`)
return undefined
}),
),
)
)
.flat()
.filter((recommendation): recommendation is Song | Album | Artist | Playlist => recommendation?.id !== undefined)
const getRecommendations = (connection: Connection) =>
connection.getRecommendations().catch((reason) => {
console.log(`Failed to fetch recommendations: ${reason}`)
return null
})
const recommendations = (await Promise.all(userConnections.map(getRecommendations))).flat().filter((recommendation): recommendation is Song | Album | Artist | Playlist => recommendation?.id !== undefined)
return Response.json({ recommendations })
}

View File

@@ -0,0 +1,41 @@
import type { RequestHandler } from '@sveltejs/kit'
import { z } from 'zod'
import { DB } from '$lib/server/db'
// * Hook middleware garruntees mixId is valid.
// * Will intercept the call if the mixId does not exist
export const GET: RequestHandler = async ({ params }) => {
const mix = (await DB.mixes.where('id', params.mixId!).first())!
return Response.json(mix satisfies Mix)
}
const mixUpdate = z.object({
name: z.string().optional(),
thumbnailTag: z.string().optional(),
description: z.string().optional(),
})
const updatedMixResponse = new Response('Updated mix.', { status: 200 })
const invalidDataResponse = new Response('Invalid Mix Data', { status: 400 })
export const PATCH: RequestHandler = async ({ params, request }) => {
const updateMixData = await request
.json()
.then((data) => mixUpdate.parse(data))
.catch(() => null)
if (!updateMixData) return invalidDataResponse
const mixId = params.mixId!
const { name, thumbnailTag, description } = updateMixData
await DB.mixes.where('id', mixId).update({ name, thumbnailTag, description })
return updatedMixResponse
}
const deletedMixResponse = new Response('Deleted mix.', { status: 200 })
export const DELETE: RequestHandler = async ({ params }) => {
await DB.mixes.where('id', params.mixId!).del()
return deletedMixResponse
}

View File

@@ -0,0 +1,28 @@
import type { RequestHandler } from '@sveltejs/kit'
import { z } from 'zod'
import { DB } from '$lib/server/db'
const isPositiveInteger = (n: number) => !Number.isNaN(n) && Number.isSafeInteger(n) && n > 0
// export const GET: RequestHandler = async ({ params, url }) => {
// const mixId = params.mixId!
// const startIndexQuery = Number(url.searchParams.get('startIndex'))
// const startIndex = isPositiveInteger(startIndexQuery) ? startIndexQuery : 0
// const limitQuery = Number(url.searchParams.get('limit'))
// const limit = isPositiveInteger(limitQuery) ? limitQuery : Number.POSITIVE_INFINITY
// const playlistItemIds = await DB.mixItems
// .select('*')
// .where('id', mixId)
// .whereBetween('index', [startIndex, startIndex + limit - 1])
// return Response.json()
// }
// export const POST: RequestHandler = async ({ params }) => {}
// export const PATCH: RequestHandler = async ({ params }) => {}
// export const DELETE: RequestHandler = async ({ params }) => {}

View File

@@ -0,0 +1,30 @@
import type { RequestHandler } from '@sveltejs/kit'
import { z } from 'zod'
import { DB } from '$lib/server/db'
export const GET: RequestHandler = async ({ params }) => {
const mix = await DB.mixes.where('userId', params.userId!).select('*')
return Response.json(mix satisfies Mix[])
}
const newMix = z.object({
name: z.string(),
thumbnailTag: z.string().optional(),
description: z.string().optional(),
})
const invalidDataResponse = new Response('Invalid Mix Data', { status: 400 })
export const POST: RequestHandler = async ({ params, request }) => {
const mixData = await request
.json()
.then((data) => newMix.parse(data))
.catch(() => null)
if (!mixData) return invalidDataResponse
const userId = params.userId!
const { name, thumbnailTag, description } = mixData
const id = await DB.mixes.insert({ id: DB.uuid(), userId, name, thumbnailTag, description, trackCount: 0, duration: 0 }, 'id')
return Response.json({ id }, { status: 201 })
}

View File

@@ -15,7 +15,7 @@ export const actions: Actions = {
const formData = await request.formData()
const { username, password, redirectLocation } = Object.fromEntries(formData)
const user = DB.getUsername(username.toString())
const user = await DB.users.where('username', username.toString()).first()
if (!user) return fail(400, { message: 'Invalid Username' })
const passwordValid = await compare(password.toString(), user.passwordHash)
@@ -34,8 +34,20 @@ export const actions: Actions = {
const { username, password } = Object.fromEntries(formData)
const passwordHash = await hash(password.toString(), 10)
const newUser = DB.addUser(username.toString(), passwordHash)
if (!newUser) return fail(400, { message: 'Username already in use' })
const newUser = await DB.users
.insert({ id: DB.uuid(), username: username.toString(), passwordHash }, '*')
.then((data) => data[0])
.catch((error: InstanceType<typeof DB.sqliteError>) => error)
if (newUser instanceof DB.sqliteError) {
switch (newUser.code) {
case 'SQLITE_CONSTRAINT_UNIQUE':
return fail(400, { message: 'Username already in use' })
default:
console.log(newUser)
return fail(500, { message: 'Failed to create user. Reason Unknown' })
}
}
const authToken = jwt.sign({ id: newUser.id, username: newUser.username }, SECRET_JWT_KEY, { expiresIn: '100d' })