Database overhall with knex.js, some things untested
This commit is contained in:
@@ -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">,</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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
42
src/lib/components/util/mixTab.svelte
Normal file
42
src/lib/components/util/mixTab.svelte
Normal 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>
|
||||
39
src/lib/components/util/navTab.svelte
Normal file
39
src/lib/components/util/navTab.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
48
src/lib/server/api-helper.ts
Normal file
48
src/lib/server/api-helper.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user