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

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