Database overhall with knex.js, some things untested
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
5
src/routes/(app)/library/+page.server.ts
Normal file
5
src/routes/(app)/library/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return { user: locals.user }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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-' })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
41
src/routes/api/v1/mixes/[mixId]/+server.ts
Normal file
41
src/routes/api/v1/mixes/[mixId]/+server.ts
Normal 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
|
||||
}
|
||||
28
src/routes/api/v1/mixes/[mixId]/items/+server.ts
Normal file
28
src/routes/api/v1/mixes/[mixId]/items/+server.ts
Normal 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 }) => {}
|
||||
30
src/routes/api/v1/users/[userId]/mixes/+server.ts
Normal file
30
src/routes/api/v1/users/[userId]/mixes/+server.ts
Normal 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 })
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user